Compare commits
27 Commits
homenewpag
...
2a983c956c
Author | SHA1 | Date | |
---|---|---|---|
2a983c956c | |||
96e11b0220 | |||
8055886082 | |||
f0e873fdd1 | |||
029dbb7732 | |||
212e3f5dda | |||
b318fbd779 | |||
ea097d9df8 | |||
aeff49ae78 | |||
391d47ed2b | |||
a67a4438ad | |||
c7ba306a57 | |||
8284385e3c | |||
2b5f787fbe | |||
08ae507c36 | |||
795ebf875a | |||
fd0000d77e | |||
97422f7c4b | |||
7d9f611fe5 | |||
8820f4e835 | |||
ac7b2de49f | |||
a8c8ae60bb | |||
78e17a94ab | |||
36c5990921 | |||
e989d402a3 | |||
65710a35be | |||
9a604b39b3 |
3
public/images/union.svg
Normal file
3
public/images/union.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 7C12.2091 7 14 8.79086 14 11C14 13.2091 12.2091 15 10 15C7.79086 15 6 13.2091 6 11C6 8.79086 7.79086 7 10 7ZM11 0C11.5523 0 12 0.447715 12 1V6.41602C11.3875 6.14842 10.7111 6 10 6C8.36426 6 6.91221 6.78565 6 8H2.55859C2.28262 8.0002 2.05859 8.22398 2.05859 8.5C2.05859 8.77602 2.28262 8.9998 2.55859 9H5.41699C5.1493 9.61255 5 10.2888 5 11H2.5C2.22386 11 2 11.2239 2 11.5C2 11.7761 2.22386 12 2.5 12H5.10059C5.25067 12.7388 5.56324 13.4186 6 14H1C0.447715 14 2.41598e-08 13.5523 0 13V1C1.93278e-07 0.447715 0.447715 1.61064e-08 1 0H11ZM10 8.5C9.72386 8.5 9.5 8.72386 9.5 9V10.5H8.5C8.22386 10.5 8 10.7239 8 11C8.00005 11.2761 8.22389 11.5 8.5 11.5H10L10.1006 11.4902C10.3284 11.4437 10.5 11.2416 10.5 11V9C10.5 8.72391 10.2761 8.50009 10 8.5ZM2.5 5C2.22386 5 2 5.22386 2 5.5C2 5.77614 2.22386 6 2.5 6H9.5C9.77614 6 10 5.77614 10 5.5C10 5.22386 9.77614 5 9.5 5H2.5ZM2.5 2C2.22386 2 2 2.22386 2 2.5C2 2.77614 2.22386 3 2.5 3H9.5C9.77614 3 10 2.77614 10 2.5C10 2.22386 9.77614 2 9.5 2H2.5Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -10,9 +10,10 @@ interface ArticleCardProps {
|
||||
article: PartsAPIArticle;
|
||||
index: number;
|
||||
onVisibilityChange?: (index: number, isVisible: boolean) => void;
|
||||
image?: string; // optional image override
|
||||
}
|
||||
|
||||
const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibilityChange }) => {
|
||||
const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibilityChange, image }) => {
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
@ -21,6 +22,12 @@ const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibi
|
||||
enabled: !!article.artId
|
||||
});
|
||||
|
||||
// MOCK: fallback image if none loaded
|
||||
const fallbackImage =
|
||||
image || // use prop if provided
|
||||
imageUrl ||
|
||||
'/images/162615.webp'; // путь к картинке из public или любой другой
|
||||
|
||||
// Проверяем и очищаем данные артикула и бренда
|
||||
const articleNumber = article.artArticleNr?.trim();
|
||||
const brandName = article.artSupBrand?.trim();
|
||||
@ -28,7 +35,10 @@ const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibi
|
||||
// Используем хук для получения цен только если есть и артикул, и бренд
|
||||
const { getPriceData, addToCart } = useCatalogPrices();
|
||||
const shouldFetchPrices = articleNumber && brandName && articleNumber !== '' && brandName !== '';
|
||||
const priceData = shouldFetchPrices ? getPriceData(articleNumber, brandName) : { minPrice: null, cheapestOffer: null, isLoading: false, hasOffers: false };
|
||||
// MOCK: fallback price data
|
||||
const priceData = shouldFetchPrices
|
||||
? getPriceData(articleNumber, brandName)
|
||||
: { minPrice: 17087, cheapestOffer: null, isLoading: false, hasOffers: true };
|
||||
|
||||
// Определяем, должен ли отображаться товар
|
||||
useEffect(() => {
|
||||
@ -66,9 +76,29 @@ const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibi
|
||||
return <CatalogProductCardSkeleton />;
|
||||
}
|
||||
|
||||
// Не отображаем ничего если товар не должен показываться
|
||||
// MOCK: всегда показывать карточку для демо
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
// return null;
|
||||
// MOCK: показываем карточку даже если не должен
|
||||
// (можно убрать это после подключения реальных данных)
|
||||
// Формируем название товара
|
||||
const title = [brandName || 'N/A', articleNumber || 'N/A'].filter(part => part !== 'N/A').join(', ');
|
||||
const brand = brandName || 'Unknown';
|
||||
let priceText = 'от 17 087 ₽';
|
||||
return (
|
||||
<CatalogProductCard
|
||||
image={fallbackImage}
|
||||
discount="-35%"
|
||||
price={priceText}
|
||||
oldPrice="22 347 ₽"
|
||||
title={title}
|
||||
brand={brand}
|
||||
articleNumber={articleNumber}
|
||||
brandName={brandName}
|
||||
artId={article.artId}
|
||||
onAddToCart={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Формируем название товара
|
||||
@ -104,7 +134,7 @@ const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibi
|
||||
|
||||
return (
|
||||
<CatalogProductCard
|
||||
image={imageUrl}
|
||||
image={fallbackImage}
|
||||
discount="Новинка"
|
||||
price={priceText}
|
||||
oldPrice=""
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import toast from "react-hot-toast";
|
||||
import CartIcon from "./CartIcon";
|
||||
|
||||
interface BestPriceCardProps {
|
||||
bestOfferType: string;
|
||||
@ -27,6 +28,11 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10);
|
||||
const maxCount = isNaN(parsedStock) ? undefined : parsedStock;
|
||||
const [count, setCount] = useState(1);
|
||||
const [inputValue, setInputValue] = useState("1");
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(count.toString());
|
||||
}, [count]);
|
||||
|
||||
const handleMinus = () => setCount(prev => Math.max(1, prev - 1));
|
||||
const handlePlus = () => {
|
||||
@ -38,7 +44,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
};
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = parseInt(e.target.value, 10);
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
if (val === "") {
|
||||
// Не обновляем count, пока не будет blur
|
||||
return;
|
||||
}
|
||||
let value = parseInt(val, 10);
|
||||
if (isNaN(value) || value < 1) value = 1;
|
||||
if (maxCount !== undefined && value > maxCount) {
|
||||
toast.error(`Максимум ${maxCount} шт.`);
|
||||
@ -47,6 +59,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
setCount(value);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (inputValue === "") {
|
||||
setInputValue("1");
|
||||
setCount(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для парсинга цены из строки
|
||||
const parsePrice = (priceStr: string): number => {
|
||||
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
|
||||
@ -54,7 +73,7 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
};
|
||||
|
||||
// Обработчик добавления в корзину
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
const handleAddToCart = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@ -69,14 +88,8 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем наличие
|
||||
if (maxCount !== undefined && count > maxCount) {
|
||||
toast.error(`Недостаточно товара в наличии. Доступно: ${maxCount} шт.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
addItem({
|
||||
const result = await addItem({
|
||||
productId: offer.productId,
|
||||
offerKey: offer.offerKey,
|
||||
name: description,
|
||||
@ -86,6 +99,7 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
price: numericPrice,
|
||||
currency: offer.currency || 'RUB',
|
||||
quantity: count,
|
||||
stock: maxCount, // передаем информацию о наличии
|
||||
deliveryTime: delivery,
|
||||
warehouse: offer.warehouse || 'Склад',
|
||||
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
|
||||
@ -93,17 +107,22 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
image: offer.image,
|
||||
});
|
||||
|
||||
// Показываем тоастер об успешном добавлении
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold">Товар добавлен в корзину!</div>
|
||||
<div className="text-sm text-gray-600">{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: '🛒',
|
||||
}
|
||||
);
|
||||
if (result.success) {
|
||||
// Показываем тоастер об успешном добавлении
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: <CartIcon size={20} color="#fff" />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Показываем ошибку
|
||||
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления в корзину:', error);
|
||||
toast.error('Ошибка добавления товара в корзину');
|
||||
@ -144,8 +163,9 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxCount}
|
||||
value={count}
|
||||
value={inputValue}
|
||||
onChange={handleInput}
|
||||
onBlur={handleInputBlur}
|
||||
className="text-block-26 w-full text-center outline-none"
|
||||
aria-label="Количество"
|
||||
/>
|
||||
|
64
src/components/BestPriceItem.tsx
Normal file
64
src/components/BestPriceItem.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
|
||||
interface BestPriceItemProps {
|
||||
image: string;
|
||||
discount: string;
|
||||
price: string;
|
||||
oldPrice: string;
|
||||
title: string;
|
||||
brand: string;
|
||||
onAddToCart?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const BestPriceItem: React.FC<BestPriceItemProps> = ({
|
||||
image,
|
||||
discount,
|
||||
price,
|
||||
oldPrice,
|
||||
title,
|
||||
brand,
|
||||
onAddToCart,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-layout-vflex bestpriceitem">
|
||||
<div className="favcardcat">
|
||||
<div className="icon-setting w-embed">
|
||||
<svg width="currenWidth" 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" stroke="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="imgitembp">
|
||||
<img
|
||||
width="auto"
|
||||
height="auto"
|
||||
alt={title}
|
||||
src={image}
|
||||
loading="lazy"
|
||||
className="image-5"
|
||||
/>
|
||||
<div className="saletagbp">{discount}</div>
|
||||
</div>
|
||||
<div className="div-block-3 bp-item-info">
|
||||
<div className="w-layout-hflex pricecartbp">
|
||||
<div className="actualprice">{price}</div>
|
||||
<div className="oldpricebp">{oldPrice}</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-120">
|
||||
<div className="nameitembp">{title}</div>
|
||||
<a href="#" className="button-icon w-inline-block" onClick={onAddToCart}>
|
||||
<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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BestPriceItem;
|
25
src/components/CartIcon.tsx
Normal file
25
src/components/CartIcon.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CartIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const CartIcon: React.FC<CartIconProps> = ({ size = 24, color = '#fff' }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartIcon;
|
@ -32,7 +32,7 @@ const CartInfo: React.FC = () => {
|
||||
<h1 className="heading">Корзина</h1>
|
||||
<div className="text-block-4">
|
||||
{summary.totalItems > 0 ? (
|
||||
<>В вашей корзине {summary.totalItems} товара на <strong>{formatPrice(summary.finalPrice)}</strong></>
|
||||
<>В вашей корзине {summary.totalItems} товара на <strong>{formatPrice(summary.totalPrice - summary.totalDiscount)}</strong></>
|
||||
) : (
|
||||
'Ваша корзина пуста'
|
||||
)}
|
||||
|
@ -38,160 +38,180 @@ const CartItem: React.FC<CartItemProps> = ({
|
||||
onRemove,
|
||||
isSummaryStep = false,
|
||||
itemNumber,
|
||||
}) => (
|
||||
<div className="w-layout-hflex cart-item">
|
||||
<div className="w-layout-hflex info-block-search-copy">
|
||||
{isSummaryStep ? (
|
||||
<div style={{ marginRight: 12, minWidth: 24, textAlign: 'center', fontWeight: 600, fontSize: 14 }}>{itemNumber}</div>
|
||||
) : (
|
||||
<div
|
||||
className={"div-block-7" + (selected ? " active" : "")}
|
||||
onClick={onSelect}
|
||||
style={{ marginRight: 12, cursor: 'pointer' }}
|
||||
>
|
||||
{selected && (
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none">
|
||||
<path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-layout-hflex block-name">
|
||||
<h4 className="heading-9-copy">{name}</h4>
|
||||
<div
|
||||
className={
|
||||
"text-block-21-copy" +
|
||||
(isSummaryStep && itemNumber === 1 ? " border-t-0" : "")
|
||||
}
|
||||
style={
|
||||
isSummaryStep && itemNumber === 1
|
||||
? { borderTop: 'none' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-block-copy w-form">
|
||||
<form className="form-copy" onSubmit={e => e.preventDefault()}>
|
||||
<input
|
||||
className="text-field-copy w-input"
|
||||
maxLength={256}
|
||||
name="Search-5"
|
||||
data-name="Search 5"
|
||||
placeholder="Комментарий"
|
||||
type="text"
|
||||
id="Search-5"
|
||||
value={comment}
|
||||
onChange={e => onComment(e.target.value)}
|
||||
disabled={isSummaryStep}
|
||||
/>
|
||||
</form>
|
||||
<div className="success-message w-form-done">
|
||||
<div>Thank you! Your submission has been received!</div>
|
||||
</div>
|
||||
<div className="error-message w-form-fail">
|
||||
<div>Oops! Something went wrong while submitting the form.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex add-to-cart-block">
|
||||
<div className="w-layout-hflex flex-block-39-copy">
|
||||
<h4 className="delivery-cart-s1">{delivery}</h4>
|
||||
<div className="text-block-21-copy-copy">{deliveryDate}</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex pcs-cart-s1">
|
||||
}) => {
|
||||
// --- Фикс для input: можно стереть, при blur пустое = 1 ---
|
||||
const [inputValue, setInputValue] = React.useState(count.toString());
|
||||
React.useEffect(() => {
|
||||
setInputValue(count.toString());
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex cart-item">
|
||||
<div className="w-layout-hflex info-block-search-copy">
|
||||
{isSummaryStep ? (
|
||||
<div className="text-block-26" style={{ fontWeight: 600, fontSize: 14 }}>{count} шт.</div>
|
||||
<div style={{ marginRight: 12, minWidth: 24, textAlign: 'center', fontWeight: 600, fontSize: 14 }}>{itemNumber}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => onCountChange && onCountChange(count - 1)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Уменьшить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count - 1)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className={"div-block-7" + (selected ? " active" : "")}
|
||||
onClick={onSelect}
|
||||
style={{ marginRight: 12, cursor: 'pointer' }}
|
||||
>
|
||||
{selected && (
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none">
|
||||
<path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="input-pcs">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={count}
|
||||
onChange={e => {
|
||||
const value = Math.max(1, parseInt(e.target.value, 10) || 1);
|
||||
onCountChange && onCountChange(value);
|
||||
}}
|
||||
className="text-block-26 w-full text-center outline-none"
|
||||
aria-label="Количество"
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => onCountChange && onCountChange(count + 1)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Увеличить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count + 1)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-39-copy-copy">
|
||||
<h4 className="price-in-cart-s1">{price}</h4>
|
||||
<div className="price-1-pcs-cart-s1">{pricePerItem}</div>
|
||||
</div>
|
||||
{!isSummaryStep && (
|
||||
<div className="w-layout-hflex control-element">
|
||||
<div className="favorite-icon w-embed" onClick={onFavorite} style={{ cursor: 'pointer', color: favorite ? '#e53935' : undefined }}>
|
||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 16.5L7.84 15.4929C3.72 11.93 1 9.57248 1 6.69619C1 4.33869 2.936 2.5 5.4 2.5C6.792 2.5 8.128 3.11798 9 4.08692C9.872 3.11798 11.208 2.5 12.6 2.5C15.064 2.5 17 4.33869 17 6.69619C17 9.57248 14.28 11.93 10.16 15.4929L9 16.5Z" fill={favorite ? "#e53935" : "currentColor"} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="w-layout-hflex block-name">
|
||||
<h4 className="heading-9-copy">{name}</h4>
|
||||
<div
|
||||
className="bdel"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Удалить из корзины"
|
||||
onClick={onRemove}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onRemove && onRemove()}
|
||||
style={{ display: 'inline-flex', cursor: 'pointer', transition: 'color 0.2s' }}
|
||||
onMouseEnter={e => {
|
||||
const path = e.currentTarget.querySelector('path');
|
||||
if (path) path.setAttribute('fill', '#ec1c24');
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const path = e.currentTarget.querySelector('path');
|
||||
if (path) path.setAttribute('fill', '#D0D0D0');
|
||||
}}
|
||||
className={
|
||||
"text-block-21-copy" +
|
||||
(isSummaryStep && itemNumber === 1 ? " border-t-0" : "")
|
||||
}
|
||||
style={
|
||||
isSummaryStep && itemNumber === 1
|
||||
? { borderTop: 'none' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-block-copy w-form">
|
||||
<form className="form-copy" onSubmit={e => e.preventDefault()}>
|
||||
<input
|
||||
className="text-field-copy w-input"
|
||||
maxLength={256}
|
||||
name="Search-5"
|
||||
data-name="Search 5"
|
||||
placeholder="Комментарий"
|
||||
type="text"
|
||||
id="Search-5"
|
||||
value={comment}
|
||||
onChange={e => onComment(e.target.value)}
|
||||
disabled={isSummaryStep}
|
||||
/>
|
||||
</form>
|
||||
<div className="success-message w-form-done">
|
||||
<div>Thank you! Your submission has been received!</div>
|
||||
</div>
|
||||
<div className="error-message w-form-fail">
|
||||
<div>Oops! Something went wrong while submitting the form.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex add-to-cart-block">
|
||||
<div className="w-layout-hflex flex-block-39-copy">
|
||||
<h4 className="delivery-cart-s1">{delivery}</h4>
|
||||
<div className="text-block-21-copy-copy">{deliveryDate}</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex pcs-cart-s1">
|
||||
{isSummaryStep ? (
|
||||
<div className="text-block-26" style={{ fontWeight: 600, fontSize: 14 }}>{count} шт.</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => onCountChange && onCountChange(count - 1)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Уменьшить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count - 1)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-pcs">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={inputValue}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
if (val === "") {
|
||||
// Не обновляем count, пока не будет blur
|
||||
return;
|
||||
}
|
||||
const valueNum = Math.max(1, parseInt(val, 10) || 1);
|
||||
onCountChange && onCountChange(valueNum);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (inputValue === "") {
|
||||
setInputValue("1");
|
||||
onCountChange && onCountChange(1);
|
||||
}
|
||||
}}
|
||||
className="text-block-26 w-full text-center outline-none"
|
||||
aria-label="Количество"
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => onCountChange && onCountChange(count + 1)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Увеличить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count + 1)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-39-copy-copy">
|
||||
<h4 className="price-in-cart-s1">{price}</h4>
|
||||
<div className="price-1-pcs-cart-s1">{pricePerItem}</div>
|
||||
</div>
|
||||
{!isSummaryStep && (
|
||||
<div className="w-layout-hflex control-element">
|
||||
<div className="favorite-icon w-embed" onClick={onFavorite} style={{ cursor: 'pointer', color: favorite ? '#e53935' : undefined }}>
|
||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.625 17.5C4.14375 17.5 3.73192 17.3261 3.3895 16.9782C3.04708 16.6304 2.87558 16.2117 2.875 15.7222V4.16667H2V2.38889H6.375V1.5H11.625V2.38889H16V4.16667H15.125V15.7222C15.125 16.2111 14.9538 16.6298 14.6114 16.9782C14.269 17.3267 13.8568 17.5006 13.375 17.5H4.625ZM6.375 13.9444H8.125V5.94444H6.375V13.9444ZM9.875 13.9444H11.625V5.94444H9.875V13.9444Z"
|
||||
fill="#D0D0D0"
|
||||
style={{ transition: 'fill 0.2s' }}
|
||||
/>
|
||||
<path d="M9 16.5L7.84 15.4929C3.72 11.93 1 9.57248 1 6.69619C1 4.33869 2.936 2.5 5.4 2.5C6.792 2.5 8.128 3.11798 9 4.08692C9.872 3.11798 11.208 2.5 12.6 2.5C15.064 2.5 17 4.33869 17 6.69619C17 9.57248 14.28 11.93 10.16 15.4929L9 16.5Z" fill={favorite ? "#e53935" : "currentColor"} />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="bdel"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Удалить из корзины"
|
||||
onClick={onRemove}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onRemove && onRemove()}
|
||||
style={{ display: 'inline-flex', cursor: 'pointer', transition: 'color 0.2s' }}
|
||||
onMouseEnter={e => {
|
||||
const path = e.currentTarget.querySelector('path');
|
||||
if (path) path.setAttribute('fill', '#ec1c24');
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const path = e.currentTarget.querySelector('path');
|
||||
if (path) path.setAttribute('fill', '#D0D0D0');
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.625 17.5C4.14375 17.5 3.73192 17.3261 3.3895 16.9782C3.04708 16.6304 2.87558 16.2117 2.875 15.7222V4.16667H2V2.38889H6.375V1.5H11.625V2.38889H16V4.16667H15.125V15.7222C15.125 16.2111 14.9538 16.6298 14.6114 16.9782C14.269 17.3267 13.8568 17.5006 13.375 17.5H4.625ZM6.375 13.9444H8.125V5.94444H6.375V13.9444ZM9.875 13.9444H11.625V5.94444H9.875V13.9444Z"
|
||||
fill="#D0D0D0"
|
||||
style={{ transition: 'fill 0.2s' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default CartItem;
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import CartItem from "./CartItem";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||
@ -8,7 +8,7 @@ interface CartListProps {
|
||||
}
|
||||
|
||||
const CartList: React.FC<CartListProps> = ({ isSummaryStep = false }) => {
|
||||
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity } = useCart();
|
||||
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity, clearError } = useCart();
|
||||
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
||||
const { items } = state;
|
||||
|
||||
@ -73,8 +73,40 @@ const CartList: React.FC<CartListProps> = ({ isSummaryStep = false }) => {
|
||||
// На втором шаге показываем только выбранные товары
|
||||
const displayItems = isSummaryStep ? items.filter(item => item.selected) : items;
|
||||
|
||||
// Автоматически очищаем ошибки через 5 секунд
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
const timer = setTimeout(() => {
|
||||
clearError();
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.error, clearError]);
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex flex-block-48">
|
||||
{/* Отображение ошибок корзины */}
|
||||
{state.error && (
|
||||
<div className="alert alert-error mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{state.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="ml-2 text-red-500 hover:text-red-700"
|
||||
aria-label="Закрыть уведомление"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-layout-vflex product-list-cart">
|
||||
{!isSummaryStep && (
|
||||
<div className="w-layout-hflex multi-control">
|
||||
@ -138,7 +170,7 @@ const CartList: React.FC<CartListProps> = ({ isSummaryStep = false }) => {
|
||||
description={item.description}
|
||||
delivery={item.deliveryTime || 'Уточняется'}
|
||||
deliveryDate={item.deliveryDate || ''}
|
||||
price={formatPrice(item.price, item.currency)}
|
||||
price={formatPrice(item.price * item.quantity, item.currency)}
|
||||
pricePerItem={`${formatPrice(item.price, item.currency)}/шт`}
|
||||
count={item.quantity}
|
||||
comment={item.comment || ''}
|
||||
|
@ -3,6 +3,7 @@ import CatalogProductCard from "./CatalogProductCard";
|
||||
import { useArticleImage } from "@/hooks/useArticleImage";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
import CartIcon from "./CartIcon";
|
||||
|
||||
interface CartRecommendedProps {
|
||||
recommendedProducts?: any[];
|
||||
@ -36,13 +37,14 @@ const RecommendedProductCard: React.FC<{
|
||||
}
|
||||
|
||||
// Добавляем товар в корзину
|
||||
addItem({
|
||||
const result = await addItem({
|
||||
productId: String(item.artId) || undefined,
|
||||
name: item.name || `${item.brand} ${item.articleNumber}`,
|
||||
description: item.name || `${item.brand} ${item.articleNumber}`,
|
||||
price: numericPrice,
|
||||
currency: 'RUB',
|
||||
quantity: 1,
|
||||
stock: undefined, // информация о наличии не доступна для рекомендуемых товаров
|
||||
image: displayImage,
|
||||
brand: item.brand,
|
||||
article: item.articleNumber,
|
||||
@ -51,17 +53,22 @@ const RecommendedProductCard: React.FC<{
|
||||
isExternal: true
|
||||
});
|
||||
|
||||
// Показываем успешный тоастер
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold">Товар добавлен в корзину!</div>
|
||||
<div className="text-sm text-gray-600">{item.name || `${item.brand} ${item.articleNumber}`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: '🛒',
|
||||
}
|
||||
);
|
||||
if (result.success) {
|
||||
// Показываем успешный тоастер
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{item.name || `${item.brand} ${item.articleNumber}`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: <CartIcon size={20} color="#fff" />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Показываем ошибку
|
||||
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления в корзину:', error);
|
||||
toast.error('Ошибка при добавлении товара в корзину');
|
||||
|
@ -42,7 +42,7 @@ const CartRecommendedProductCard: React.FC<CartRecommendedProductCardProps> = ({
|
||||
<Link href="/cart" className="link-block-4-copy w-inline-block">
|
||||
<div className="div-block-25">
|
||||
<span className="icon-setting w-embed">
|
||||
<svg width="currentWidht" height="currentHight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
|
@ -657,9 +657,7 @@ const CartSummary: React.FC<CartSummaryProps> = ({ step, setStep }) => {
|
||||
<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 + (selectedDeliveryAddress ? 0 : summary.deliveryPrice)
|
||||
)}
|
||||
{formatPrice(summary.totalPrice - summary.totalDiscount)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@ -835,9 +833,7 @@ const CartSummary: React.FC<CartSummaryProps> = ({ step, setStep }) => {
|
||||
<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 + (selectedDeliveryAddress ? 0 : summary.deliveryPrice)
|
||||
)}
|
||||
{formatPrice(summary.totalPrice - summary.totalDiscount)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
|
@ -26,17 +26,35 @@ const CatalogInfoHeader: React.FC<CatalogInfoHeaderProps> = ({
|
||||
<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">
|
||||
<div
|
||||
className="w-layout-hflex flex-block-7"
|
||||
itemScope
|
||||
itemType="https://schema.org/BreadcrumbList"
|
||||
>
|
||||
{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
|
||||
href={bc.href}
|
||||
className="link-block w-inline-block"
|
||||
itemProp="itemListElement"
|
||||
itemScope
|
||||
itemType="https://schema.org/ListItem"
|
||||
>
|
||||
<div itemProp="name">{bc.label}</div>
|
||||
<meta itemProp="position" content={String(idx + 1)} />
|
||||
<meta itemProp="item" content={bc.href} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="link-block-2 w-inline-block">
|
||||
<div>{bc.label}</div>
|
||||
<span
|
||||
className="link-block-2 w-inline-block"
|
||||
itemProp="itemListElement"
|
||||
itemScope
|
||||
itemType="https://schema.org/ListItem"
|
||||
>
|
||||
<div itemProp="name">{bc.label}</div>
|
||||
<meta itemProp="position" content={String(idx + 1)} />
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
@ -95,7 +95,12 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex flex-block-15-copy" data-article-card="visible">
|
||||
<div
|
||||
className="w-layout-vflex flex-block-15-copy"
|
||||
data-article-card="visible"
|
||||
itemScope
|
||||
itemType="https://schema.org/Product"
|
||||
>
|
||||
<div
|
||||
className={`favcardcat ${isItemFavorite ? 'favorite-active' : ''}`}
|
||||
onClick={handleFavoriteClick}
|
||||
@ -113,7 +118,15 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
||||
|
||||
{/* Делаем картинку и контент кликабельными для перехода на 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" />
|
||||
<img
|
||||
src={displayImage}
|
||||
loading="lazy"
|
||||
width="Auto"
|
||||
height="Auto"
|
||||
alt={title}
|
||||
className="image-5"
|
||||
itemProp="image"
|
||||
/>
|
||||
<div className="text-block-7">{discount}</div>
|
||||
</Link>
|
||||
|
||||
@ -122,12 +135,18 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
||||
{priceElement ? (
|
||||
<div className="text-block-8">{priceElement}</div>
|
||||
) : (
|
||||
<div className="text-block-8">{price}</div>
|
||||
<div className="text-block-8" itemProp="offers" itemScope itemType="https://schema.org/Offer">
|
||||
<span itemProp="price">{price}</span>
|
||||
<meta itemProp="priceCurrency" content={currency} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-block-9">{oldPrice}</div>
|
||||
</div>
|
||||
<div className="text-block-10">{title}</div>
|
||||
<div className="text-block-11">{brand}</div>
|
||||
<div className="text-block-10" itemProp="name">{title}</div>
|
||||
<div className="text-block-11" itemProp="brand" itemScope itemType="https://schema.org/Brand">
|
||||
<span itemProp="name">{brand}</span>
|
||||
</div>
|
||||
<meta itemProp="sku" content={articleNumber || ''} />
|
||||
</Link>
|
||||
|
||||
{/* Обновляем кнопку купить */}
|
||||
|
271
src/components/CookieConsent.tsx
Normal file
271
src/components/CookieConsent.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CookiePreferences, initializeAnalytics, initializeMarketing } from '@/lib/cookie-utils';
|
||||
|
||||
interface CookieConsentProps {
|
||||
onAccept?: () => void;
|
||||
onDecline?: () => void;
|
||||
onConfigure?: (preferences: CookiePreferences) => void;
|
||||
}
|
||||
|
||||
const CookieConsent: React.FC<CookieConsentProps> = ({ onAccept, onDecline, onConfigure }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||
necessary: true, // Всегда включены
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, есть ли уже согласие в localStorage
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (!cookieConsent) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
const allAccepted = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true,
|
||||
};
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify(allAccepted));
|
||||
|
||||
// Инициализируем сервисы после согласия
|
||||
initializeAnalytics();
|
||||
initializeMarketing();
|
||||
|
||||
setIsVisible(false);
|
||||
onAccept?.();
|
||||
};
|
||||
|
||||
const handleDeclineAll = () => {
|
||||
const onlyNecessary = {
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
};
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify(onlyNecessary));
|
||||
setIsVisible(false);
|
||||
onDecline?.();
|
||||
};
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
localStorage.setItem('cookieConsent', 'configured');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify(preferences));
|
||||
|
||||
// Инициализируем сервисы согласно настройкам
|
||||
if (preferences.analytics) {
|
||||
initializeAnalytics();
|
||||
}
|
||||
if (preferences.marketing) {
|
||||
initializeMarketing();
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
onConfigure?.(preferences);
|
||||
};
|
||||
|
||||
const togglePreference = (key: keyof CookiePreferences) => {
|
||||
if (key === 'necessary') return; // Необходимые cookies нельзя отключить
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key]
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-lg cookie-consent-enter">
|
||||
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
||||
{!showDetails ? (
|
||||
// Основной вид
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{/* Текст согласия */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Иконка cookie */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-gray-600">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1.5 3.5c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm3 2c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-6 1c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm2.5 3c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm4.5-1c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-2 4c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-3.5-2c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-950 mb-2">
|
||||
Мы используем файлы cookie
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Наш сайт использует файлы cookie для улучшения работы сайта, персонализации контента и анализа трафика.
|
||||
Продолжая использовать сайт, вы соглашаетесь с нашей{' '}
|
||||
<a
|
||||
href="/privacy-policy"
|
||||
className="text-red-600 hover:text-red-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
политикой конфиденциальности
|
||||
</a>
|
||||
{' '}и использованием файлов cookie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 md:flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowDetails(true)}
|
||||
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 min-w-[120px]"
|
||||
>
|
||||
Настроить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 min-w-[120px]"
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 min-w-[120px]"
|
||||
>
|
||||
Принять все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Детальный вид с настройками
|
||||
<div className="space-y-6">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-950">
|
||||
Настройки файлов cookie
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="text-gray-500 hover:text-gray-700 p-1"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M15 5L5 15M5 5l10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Настройки cookies */}
|
||||
<div className="space-y-4">
|
||||
{/* Необходимые cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-medium text-gray-950">Необходимые cookies</h4>
|
||||
<span className="text-xs px-2 py-1 bg-gray-200 text-gray-600 rounded">Обязательные</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Эти файлы cookie необходимы для работы сайта и не могут быть отключены.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<div className="w-12 h-6 bg-red-600 rounded-full flex items-center justify-end px-1">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Аналитические cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-950 mb-2">Аналитические cookies</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Помогают нам понять, как посетители взаимодействуют с сайтом.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => togglePreference('analytics')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.analytics ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Маркетинговые cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-950 mb-2">Маркетинговые cookies</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Используются для отслеживания посетителей и показа релевантной рекламы.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => togglePreference('marketing')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.marketing ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Функциональные cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-950 mb-2">Функциональные cookies</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Обеспечивают расширенную функциональность и персонализацию.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => togglePreference('functional')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.functional ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleDeclineAll}
|
||||
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
|
||||
>
|
||||
Только необходимые
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePreferences}
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
|
||||
>
|
||||
Сохранить настройки
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
|
||||
>
|
||||
Принять все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieConsent;
|
@ -1,7 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||
import toast from "react-hot-toast";
|
||||
import CartIcon from "./CartIcon";
|
||||
|
||||
const INITIAL_OFFERS_LIMIT = 5;
|
||||
|
||||
@ -52,8 +53,16 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
|
||||
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
|
||||
);
|
||||
const [inputValues, setInputValues] = useState<{ [key: number]: string }>(
|
||||
offers.reduce((acc, _, index) => ({ ...acc, [index]: "1" }), {})
|
||||
);
|
||||
const [quantityErrors, setQuantityErrors] = useState<{ [key: number]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
setInputValues(offers.reduce((acc, _, index) => ({ ...acc, [index]: "1" }), {}));
|
||||
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
|
||||
}, [offers.length]);
|
||||
|
||||
const displayedOffers = offers.slice(0, visibleOffersCount);
|
||||
const hasMoreOffers = visibleOffersCount < offers.length;
|
||||
|
||||
@ -83,31 +92,44 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
};
|
||||
|
||||
const handleQuantityInput = (index: number, value: string) => {
|
||||
const offer = offers[index];
|
||||
const availableStock = parseStock(offer.pcs);
|
||||
let num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 1) num = 1;
|
||||
if (num > availableStock) {
|
||||
toast.error(`Максимум ${availableStock} шт.`);
|
||||
return;
|
||||
}
|
||||
setQuantities(prev => ({ ...prev, [index]: num }));
|
||||
const handleInputChange = (idx: number, val: string) => {
|
||||
setInputValues(prev => ({ ...prev, [idx]: val }));
|
||||
if (val === "") return;
|
||||
const valueNum = Math.max(1, parseInt(val, 10) || 1);
|
||||
setQuantities(prev => ({ ...prev, [idx]: valueNum }));
|
||||
};
|
||||
|
||||
const handleAddToCart = (offer: CoreProductCardOffer, index: number) => {
|
||||
const handleInputBlur = (idx: number) => {
|
||||
if (inputValues[idx] === "") {
|
||||
setInputValues(prev => ({ ...prev, [idx]: "1" }));
|
||||
setQuantities(prev => ({ ...prev, [idx]: 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinus = (idx: number) => {
|
||||
setQuantities(prev => {
|
||||
const newVal = Math.max(1, (prev[idx] || 1) - 1);
|
||||
setInputValues(vals => ({ ...vals, [idx]: newVal.toString() }));
|
||||
return { ...prev, [idx]: newVal };
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlus = (idx: number, maxCount?: number) => {
|
||||
setQuantities(prev => {
|
||||
let newVal = (prev[idx] || 1) + 1;
|
||||
if (maxCount !== undefined) newVal = Math.min(newVal, maxCount);
|
||||
setInputValues(vals => ({ ...vals, [idx]: newVal.toString() }));
|
||||
return { ...prev, [idx]: newVal };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToCart = async (offer: CoreProductCardOffer, index: number) => {
|
||||
const quantity = quantities[index] || 1;
|
||||
const availableStock = parseStock(offer.pcs);
|
||||
|
||||
// Проверяем наличие
|
||||
if (quantity > availableStock) {
|
||||
toast.error(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPrice = parsePrice(offer.price);
|
||||
|
||||
addItem({
|
||||
const result = await addItem({
|
||||
productId: offer.productId,
|
||||
offerKey: offer.offerKey,
|
||||
name: name,
|
||||
@ -117,6 +139,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
price: numericPrice,
|
||||
currency: offer.currency || 'RUB',
|
||||
quantity: quantity,
|
||||
stock: availableStock, // передаем информацию о наличии
|
||||
deliveryTime: parseDeliveryTime(offer.days),
|
||||
warehouse: offer.warehouse || 'Склад',
|
||||
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
|
||||
@ -124,17 +147,22 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
image: image,
|
||||
});
|
||||
|
||||
// Показываем тоастер вместо alert
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold">Товар добавлен в корзину!</div>
|
||||
<div className="text-sm text-gray-600">{`${brand} ${article} (${quantity} шт.)`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: '🛒',
|
||||
}
|
||||
);
|
||||
if (result.success) {
|
||||
// Показываем тоастер вместо alert
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${brand} ${article} (${quantity} шт.)`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: <CartIcon size={20} color="#fff" />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Показываем ошибку
|
||||
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик клика по сердечку
|
||||
@ -291,6 +319,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
<div className="w-layout-vflex product-list-search-s1">
|
||||
{displayedOffers.map((offer, idx) => {
|
||||
const isLast = idx === displayedOffers.length - 1;
|
||||
const maxCount = parseStock(offer.pcs);
|
||||
return (
|
||||
<div
|
||||
className="w-layout-hflex product-item-search-s1"
|
||||
@ -317,43 +346,48 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
<div className="w-layout-hflex add-to-cart-block-s1">
|
||||
<div className="w-layout-hflex flex-block-82">
|
||||
<div className="w-layout-hflex pcs-cart-s1">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) - 1).toString())}
|
||||
onClick={() => handleMinus(idx)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Уменьшить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handleMinus(idx)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="input-pcs">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={parseStock(offer.pcs)}
|
||||
value={quantities[idx] || 1}
|
||||
onChange={e => handleQuantityInput(idx, e.target.value)}
|
||||
max={maxCount}
|
||||
value={inputValues[idx]}
|
||||
onChange={e => handleInputChange(idx, e.target.value)}
|
||||
onBlur={() => handleInputBlur(idx)}
|
||||
className="text-block-26 w-full text-center outline-none"
|
||||
aria-label="Количество"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className="minus-plus"
|
||||
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) + 1).toString())}
|
||||
onClick={() => handlePlus(idx, maxCount)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label="Увеличить количество"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handlePlus(idx, maxCount)}
|
||||
role="button"
|
||||
>
|
||||
<div className="pluspcs w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
25
src/components/DeleteCartIcon.tsx
Normal file
25
src/components/DeleteCartIcon.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DeleteCartIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const DeleteCartIcon: React.FC<DeleteCartIconProps> = ({ size = 24, color = '#ec1c24' }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 30 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCartIcon;
|
@ -24,7 +24,7 @@ const Footer = () => (
|
||||
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
|
||||
<div className="dropdown-toggle-2 w-dropdown-toggle">
|
||||
<div className="text-block-17">Покупателям</div>
|
||||
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
|
||||
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<nav className="dropdown-list-3 w-dropdown-list">
|
||||
<a href="#" className="dropdown-link-2 w-dropdown-link">Оплата</a>
|
||||
@ -43,7 +43,7 @@ const Footer = () => (
|
||||
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
|
||||
<div className="dropdown-toggle-2 w-dropdown-toggle">
|
||||
<div className="text-block-17">Сотрудничество</div>
|
||||
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
|
||||
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<nav className="dropdown-list-3 w-dropdown-list">
|
||||
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
|
||||
@ -62,7 +62,7 @@ const Footer = () => (
|
||||
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
|
||||
<div className="dropdown-toggle-2 w-dropdown-toggle">
|
||||
<div className="text-block-17">PROTEK</div>
|
||||
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
|
||||
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<nav className="dropdown-list-3 w-dropdown-list">
|
||||
<a href="#" className="dropdown-link-2 w-dropdown-link">Вакансии</a>
|
||||
@ -81,7 +81,7 @@ const Footer = () => (
|
||||
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
|
||||
<div className="dropdown-toggle-2 w-dropdown-toggle">
|
||||
<div className="text-block-17">Оферта</div>
|
||||
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
|
||||
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<nav className="dropdown-list-3 w-dropdown-list">
|
||||
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
|
||||
|
@ -37,10 +37,8 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
// Если мы находимся на странице search-result, восстанавливаем поисковый запрос
|
||||
if (router.pathname === '/search-result') {
|
||||
const { article, brand } = router.query;
|
||||
if (article && brand && typeof article === 'string' && typeof brand === 'string') {
|
||||
// Формируем поисковый запрос из артикула и бренда
|
||||
setSearchQuery(`${brand} ${article}`);
|
||||
} else if (article && typeof article === 'string') {
|
||||
if (article && typeof article === 'string') {
|
||||
// Отображаем только артикул, без бренда
|
||||
setSearchQuery(article);
|
||||
}
|
||||
}
|
||||
@ -159,20 +157,20 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
}, []);
|
||||
|
||||
// Скрытие 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);
|
||||
}, []);
|
||||
// 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 => {
|
||||
@ -360,7 +358,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="top_head">
|
||||
{/* <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>
|
||||
@ -375,7 +373,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
<div className="w-layout-hflex flex-block-2">
|
||||
<div className="w-layout-hflex flex-block-3">
|
||||
<div className="w-layout-hflex flex-block-77-copy">
|
||||
<div className="code-embed-4 w-embed"><svg width="currentwidth" height="currenthight" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.51667 8.99167C6.71667 11.35 8.65 13.275 11.0083 14.4833L12.8417 12.65C13.0667 12.425 13.4 12.35 13.6917 12.45C14.625 12.7583 15.6333 12.925 16.6667 12.925C17.125 12.925 17.5 13.3 17.5 13.7583V16.6667C17.5 17.125 17.125 17.5 16.6667 17.5C8.84167 17.5 2.5 11.1583 2.5 3.33333C2.5 2.875 2.875 2.5 3.33333 2.5H6.25C6.70833 2.5 7.08333 2.875 7.08333 3.33333C7.08333 4.375 7.25 5.375 7.55833 6.30833C7.65 6.6 7.58333 6.925 7.35 7.15833L5.51667 8.99167Z" fill="currentColor" /></svg></div>
|
||||
<div className="code-embed-4 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.51667 8.99167C6.71667 11.35 8.65 13.275 11.0083 14.4833L12.8417 12.65C13.0667 12.425 13.4 12.35 13.6917 12.45C14.625 12.7583 15.6333 12.925 16.6667 12.925C17.125 12.925 17.5 13.3 17.5 13.7583V16.6667C17.5 17.125 17.125 17.5 16.6667 17.5C8.84167 17.5 2.5 11.1583 2.5 3.33333C2.5 2.875 2.875 2.5 3.33333 2.5H6.25C6.70833 2.5 7.08333 2.875 7.08333 3.33333C7.08333 4.375 7.25 5.375 7.55833 6.30833C7.65 6.6 7.58333 6.925 7.35 7.15833L5.51667 8.99167Z" fill="currentColor" /></svg></div>
|
||||
<div className="phone-copy">+7 (495) 260-20-60</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -383,24 +381,47 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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">
|
||||
<Link href="/" className="code-embed-15 w-embed" style={{ display: 'block', cursor: 'pointer' }}>
|
||||
<svg width="190" height="72" viewBox="0 0 190 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M138.377 29.5883V23.1172H112.878V29.5883H138.377Z" fill="white"></path>
|
||||
<path d="M107.423 18.1195C109.21 18.1195 110.658 16.6709 110.658 14.884C110.658 13.097 109.21 11.6484 107.423 11.6484L88.395 11.6484C86.6082 11.6484 85.1596 13.097 85.1596 14.884C85.1596 16.6709 86.6082 18.1195 88.395 18.1195H107.423Z" fill="white"></path>
|
||||
<path d="M130.288 34.2491C128.773 35.3865 126.89 36.0628 124.852 36.0628C119.849 36.0628 115.791 32.0052 115.791 27.0013C115.791 21.9974 119.849 17.9399 124.852 17.9399C129.856 17.9399 133.913 21.9974 133.913 27.0013C133.913 27.9022 133.779 28.7696 133.536 29.5893H140.169C140.31 28.7481 140.384 27.8831 140.384 27.0013C140.384 18.4226 133.431 11.4688 124.852 11.4688C116.274 11.4688 109.32 18.4226 109.32 27.0013C109.32 35.5801 116.274 42.5339 124.852 42.5339C129.249 42.5339 133.218 40.7058 136.045 37.769L130.288 34.2491Z" fill="white"></path>
|
||||
<path d="M148.633 11.4531H148.631C146.629 11.4531 145.006 13.0761 145.006 15.0782V38.9075C145.006 40.9096 146.629 42.5326 148.631 42.5326H148.633C150.635 42.5326 152.258 40.9096 152.258 38.9075V15.0782C152.258 13.0761 150.635 11.4531 148.633 11.4531Z" fill="white"></path>
|
||||
<path d="M168.935 36.3511L154.515 21.9297L149.387 27.0578L163.807 41.4792C164.489 42.1603 165.411 42.5402 166.371 42.5402C169.602 42.5402 171.22 38.6356 168.935 36.3511Z" fill="white"></path>
|
||||
<path d="M168.937 17.7751L154.733 31.979L149.605 26.8509L163.809 12.6469C164.49 11.9659 165.412 11.5859 166.373 11.5859C169.603 11.5859 171.221 15.4906 168.937 17.7751Z" fill="white"></path>
|
||||
<path d="M186.029 36.3511L171.608 21.9297L166.48 27.0578L180.901 41.4792C181.582 42.1603 182.505 42.5402 183.465 42.5402C186.696 42.5402 188.314 38.6356 186.029 36.3511Z" fill="#EC1C24"></path>
|
||||
<path d="M186.029 17.7751L171.587 32.218L166.459 27.0898L180.901 12.6469C181.582 11.9659 182.505 11.5859 183.465 11.5859C186.696 11.5859 188.314 15.4906 186.029 17.7751Z" fill="#EC1C24"></path>
|
||||
<path d="M3.6249 50.4184C1.62248 50.4184 0 48.7958 0 46.7933V11.4531L7.2522 14.3207V46.7933C7.2522 48.7958 5.62971 50.4184 3.62729 50.4184H3.6249Z" fill="white"></path>
|
||||
<path d="M97.9491 42.5353C95.9467 42.5353 94.3242 40.9128 94.3242 38.9103V0L101.576 2.86755V38.9103C101.576 40.9128 99.9539 42.5353 97.9515 42.5353H97.9491Z" fill="white"></path>
|
||||
<path d="M38.578 42.5326C36.5756 42.5326 34.9531 40.91 34.9531 38.9075V11.4531L42.2053 14.3207V38.9075C42.2053 40.91 40.5828 42.5326 38.5804 42.5326H38.578Z" fill="white"></path>
|
||||
<path d="M51.334 11.4555C42.7508 11.4555 35.7949 18.4141 35.7949 26.9953H42.2705C42.2705 21.989 46.3279 17.929 51.3364 17.929C52.0102 17.929 52.6649 18.0055 53.2958 18.1441C54.2301 16.0723 55.4798 14.1749 56.9876 12.5141C55.2361 11.8307 53.3316 11.4531 51.3364 11.4531L51.334 11.4555Z" fill="white"></path>
|
||||
<path d="M70.4707 11.4531C61.8875 11.4531 54.9316 18.4117 54.9316 26.9929C54.9316 35.574 61.8899 42.5326 70.4707 42.5326C79.0515 42.5326 86.0098 35.574 86.0098 26.9929C86.0098 18.4117 79.0515 11.4531 70.4707 11.4531ZM70.4707 36.0591C65.4647 36.0591 61.4049 32.0015 61.4049 26.9929C61.4049 21.9842 65.4623 17.9266 70.4707 17.9266C75.4791 17.9266 79.5365 21.9842 79.5365 26.9929C79.5365 32.0015 75.4791 36.0591 70.4707 36.0591Z" fill="white"></path>
|
||||
<path d="M16.2309 11.4531C7.64774 11.4531 0.689453 18.4093 0.689453 26.9929C0.689453 35.5764 7.64774 42.5326 16.2285 42.5326C24.8093 42.5326 31.7676 35.574 31.7676 26.9929C31.7676 18.4117 24.8117 11.4531 16.2309 11.4531ZM16.2309 36.0591C11.2249 36.0591 7.16506 32.0015 7.16506 26.9929C7.16506 21.9842 11.2225 17.9266 16.2309 17.9266C21.2393 17.9266 25.2967 21.9842 25.2967 26.9929C25.2967 32.0015 21.2393 36.0591 16.2309 36.0591Z" fill="white"></path>
|
||||
<rect width="53.354" height="21.8647" rx="8" transform="matrix(0.991808 -0.127739 0.127728 0.991809 134.291 50.3047)" fill="#EC1C24"></rect>
|
||||
<path d="M141.15 66.1413L144.154 54.4607L146.879 54.1098L152.697 64.6542L149.925 65.0112L149.085 63.3647L144.317 63.9787L143.906 65.7864L141.15 66.1413ZM144.828 61.5681L147.98 61.1621L145.874 57.0626L144.828 61.5681Z" fill="white"></path>
|
||||
<path d="M153.767 64.5163L152.337 53.4068L157.579 52.7316C158.076 52.6677 158.536 52.6615 158.962 52.7131C159.396 52.7528 159.781 52.868 160.117 53.0587C160.462 53.2376 160.749 53.5038 160.977 53.8573C161.203 54.2003 161.353 54.6542 161.426 55.2191C161.481 55.648 161.433 56.0689 161.283 56.4818C161.132 56.8947 160.885 57.2296 160.543 57.4864C161.063 57.6108 161.499 57.8736 161.852 58.2749C162.213 58.6643 162.438 59.2043 162.527 59.8947C162.615 60.5746 162.561 61.1559 162.365 61.6383C162.179 62.109 161.886 62.5029 161.487 62.8202C161.097 63.1257 160.634 63.366 160.099 63.5414C159.563 63.7167 158.994 63.8431 158.392 63.9206L153.767 64.5163ZM156.032 61.8478L158.345 61.55C158.609 61.516 158.844 61.4645 159.049 61.3954C159.264 61.3146 159.44 61.2228 159.577 61.12C159.724 61.0055 159.824 60.8702 159.879 60.7143C159.944 60.5465 159.963 60.3632 159.937 60.1644C159.91 59.9552 159.851 59.7874 159.76 59.6609C159.679 59.5331 159.565 59.4361 159.416 59.3701C159.267 59.2937 159.095 59.252 158.901 59.2451C158.706 59.2277 158.487 59.2347 158.244 59.266L155.741 59.5883L156.032 61.8478ZM155.472 57.5013L157.516 57.2382C157.769 57.2055 157.987 57.1508 158.171 57.074C158.365 56.9959 158.519 56.9016 158.634 56.7911C158.748 56.6806 158.828 56.5533 158.874 56.4092C158.931 56.2637 158.947 56.102 158.924 55.9242C158.893 55.6836 158.811 55.5027 158.677 55.3817C158.553 55.2489 158.387 55.1692 158.18 55.1427C157.971 55.1058 157.724 55.1057 157.439 55.1424L155.206 55.43L155.472 57.5013Z" fill="white"></path>
|
||||
<path d="M166.971 62.8158L165.843 54.06L162.469 54.4945L162.166 52.1408L171.511 50.9373L171.814 53.291L168.409 53.7296L169.536 62.4854L166.971 62.8158Z" fill="white"></path>
|
||||
<path d="M178.85 61.4134C177.699 61.5617 176.671 61.4548 175.766 61.0929C174.87 60.7191 174.14 60.1378 173.577 59.3489C173.012 58.5496 172.658 57.5903 172.514 56.471C172.366 55.3203 172.469 54.2913 172.825 53.3842C173.189 52.4652 173.764 51.7159 174.548 51.1364C175.331 50.5464 176.297 50.1773 177.448 50.029C178.578 49.8835 179.591 49.9924 180.485 50.3557C181.391 50.7176 182.13 51.2924 182.704 52.0799C183.287 52.8555 183.652 53.8135 183.799 54.9537C183.943 56.073 183.839 57.0968 183.486 58.0248C183.132 58.9425 182.559 59.7022 181.767 60.304C180.984 60.894 180.012 61.2638 178.85 61.4134ZM178.588 59.0065C179.306 58.914 179.866 58.6771 180.268 58.2957C180.67 57.9143 180.939 57.4596 181.075 56.9316C181.211 56.4037 181.245 55.8782 181.178 55.3551C181.128 54.9681 181.02 54.5885 180.854 54.2164C180.698 53.843 180.478 53.5097 180.194 53.2167C179.92 52.9223 179.58 52.7003 179.174 52.5505C178.768 52.4007 178.286 52.3618 177.726 52.4339C177.018 52.525 176.464 52.7613 176.062 53.1427C175.659 53.5136 175.384 53.9637 175.238 54.4931C175.102 55.021 175.07 55.5622 175.141 56.1167C175.212 56.6711 175.386 57.1858 175.662 57.6607C175.938 58.1356 176.318 58.5014 176.801 58.7581C177.296 59.0135 177.892 59.0963 178.588 59.0065Z" fill="white"></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<div data-animation="default" data-collapse="all" data-duration="400" data-easing="ease-in" data-easing2="ease" role="banner" className="topnav 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">
|
||||
<div className="code-embed-5 w-embed"><svg width="30" height="18" viewBox="0 0 30 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 0H30V3H0V0Z" fill="currentColor"></path>
|
||||
<path d="M0 7.5H30V10.5H0V7.5Z" fill="currentColor"></path>
|
||||
<path d="M0 15H30V18H0V15Z" fill="currentColor"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-block w-form" style={{ position: 'relative' }}>
|
||||
<div className="searcj w-form" style={{ position: 'relative' }}>
|
||||
<form
|
||||
id="custom-search-form"
|
||||
name="custom-search-form"
|
||||
@ -736,6 +757,11 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-76">
|
||||
<Link href="/profile-history" className="button_h w-inline-block">
|
||||
|
||||
<img src="/images/union.svg" alt="История заказов" width={22} height={10} />
|
||||
|
||||
</Link>
|
||||
<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>
|
||||
|
20
src/components/JsonLdScript.tsx
Normal file
20
src/components/JsonLdScript.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { generateJsonLdScript } from '@/lib/schema';
|
||||
|
||||
interface JsonLdScriptProps {
|
||||
schema: object;
|
||||
}
|
||||
|
||||
// Компонент для вставки JSON-LD разметки
|
||||
const JsonLdScript: React.FC<JsonLdScriptProps> = ({ schema }) => {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: generateJsonLdScript(schema)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonLdScript;
|
@ -13,6 +13,7 @@ const menuItems = [
|
||||
{ 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' },
|
||||
{ label: 'Настройки cookies', href: '/profile-cookie-settings', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/b39907028aa6baf08adc313aed84d1294f2be013?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
|
||||
];
|
||||
|
||||
const financeItems = [
|
||||
|
@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
||||
import Header from "./Header";
|
||||
import AuthModal from "./auth/AuthModal";
|
||||
import MobileMenuBottomSection from "./MobileMenuBottomSection";
|
||||
import IndexTopMenuNav from "./index/IndexTopMenuNav";
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
@ -30,7 +31,10 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
onSuccess={handleAuthSuccess}
|
||||
/>
|
||||
</header>
|
||||
<main className="pt-[108px] md:pt-[131px]">{children}</main>
|
||||
|
||||
<main className="pt-[62px] md:pt-[63px]">
|
||||
<IndexTopMenuNav />
|
||||
{children}</main>
|
||||
<MobileMenuBottomSection onOpenAuthModal={() => setAuthModalOpen(true)} />
|
||||
</>
|
||||
);
|
||||
|
98
src/components/MetaTags.tsx
Normal file
98
src/components/MetaTags.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface MetaTagsProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
ogUrl?: string;
|
||||
twitterTitle?: string;
|
||||
twitterDescription?: string;
|
||||
twitterImage?: string;
|
||||
canonical?: string;
|
||||
robots?: string;
|
||||
author?: string;
|
||||
viewport?: string;
|
||||
charset?: string;
|
||||
}
|
||||
|
||||
const MetaTags: React.FC<MetaTagsProps> = ({
|
||||
title = 'Protek - Автозапчасти и аксессуары',
|
||||
description = 'Protek - широкий ассортимент автозапчастей и аксессуаров для всех марок автомобилей. Быстрая доставка, гарантия качества.',
|
||||
keywords = 'автозапчасти, запчасти, автомобили, аксессуары, доставка, protek',
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage = '/images/og-image.jpg',
|
||||
ogUrl,
|
||||
twitterTitle,
|
||||
twitterDescription,
|
||||
twitterImage,
|
||||
canonical,
|
||||
robots = 'index, follow',
|
||||
author = 'Protek',
|
||||
viewport = 'width=device-width, initial-scale=1',
|
||||
charset = 'utf-8'
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const baseUrl = 'https://protek.ru'; // Замените на ваш домен
|
||||
|
||||
const currentUrl = ogUrl || `${baseUrl}${router.asPath}`;
|
||||
const canonicalUrl = canonical || currentUrl;
|
||||
|
||||
const finalOgTitle = ogTitle || title;
|
||||
const finalOgDescription = ogDescription || description;
|
||||
const finalTwitterTitle = twitterTitle || title;
|
||||
const finalTwitterDescription = twitterDescription || description;
|
||||
const finalTwitterImage = twitterImage || ogImage;
|
||||
|
||||
return (
|
||||
<Head>
|
||||
{/* Базовые meta-теги */}
|
||||
<meta charSet={charset} />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="author" content={author} />
|
||||
<meta name="viewport" content={viewport} />
|
||||
<meta name="robots" content={robots} />
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
{/* Open Graph теги */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={finalOgTitle} />
|
||||
<meta property="og:description" content={finalOgDescription} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:url" content={currentUrl} />
|
||||
<meta property="og:site_name" content="Protek" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
|
||||
{/* Twitter Card теги */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={finalTwitterTitle} />
|
||||
<meta name="twitter:description" content={finalTwitterDescription} />
|
||||
<meta name="twitter:image" content={finalTwitterImage} />
|
||||
|
||||
{/* Favicon и иконки */}
|
||||
<link href="/images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
|
||||
{/* Preconnect для производительности */}
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
|
||||
{/* Дополнительные meta-теги для SEO */}
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="theme-color" content="#dc2626" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Protek" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetaTags;
|
@ -59,19 +59,13 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
const handleAddToCart = async () => {
|
||||
const availableStock = parseStock(stock);
|
||||
|
||||
// Проверяем наличие
|
||||
if (count > availableStock) {
|
||||
alert(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPrice = parsePrice(price);
|
||||
const numericOldPrice = oldPrice ? parsePrice(oldPrice) : undefined;
|
||||
|
||||
addItem({
|
||||
const result = await addItem({
|
||||
productId: productId,
|
||||
offerKey: offerKey,
|
||||
name: title,
|
||||
@ -81,6 +75,7 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
|
||||
originalPrice: numericOldPrice,
|
||||
currency: currency,
|
||||
quantity: count,
|
||||
stock: availableStock, // передаем информацию о наличии
|
||||
deliveryTime: deliveryTime || delivery,
|
||||
warehouse: warehouse || address,
|
||||
supplier: supplier,
|
||||
@ -88,8 +83,13 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
|
||||
image: image,
|
||||
});
|
||||
|
||||
// Показываем уведомление о добавлении
|
||||
alert(`Товар "${title}" добавлен в корзину (${count} шт.)`);
|
||||
if (result.success) {
|
||||
// Показываем уведомление о добавлении
|
||||
alert(`Товар "${title}" добавлен в корзину (${count} шт.)`);
|
||||
} else {
|
||||
// Показываем ошибку
|
||||
alert(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -156,10 +156,15 @@ const QuickDetailSection: React.FC<QuickDetailSectionProps> = ({
|
||||
};
|
||||
|
||||
const handleUnitClick = (unit: LaximoUnit) => {
|
||||
setSelectedUnit({
|
||||
...unit,
|
||||
ssd: unit.ssd || ssd // Сохраняем правильный SSD в selectedUnit
|
||||
// ИСПРАВЛЕНИЕ: Сохраняем SSD узла из API ответа
|
||||
console.log('🔍 handleUnitClick - сохраняем узел с SSD:', {
|
||||
unitId: unit.unitid,
|
||||
unitName: unit.name,
|
||||
unitSsd: unit.ssd ? `${unit.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
unitSsdLength: unit.ssd?.length
|
||||
});
|
||||
|
||||
setSelectedUnit(unit); // Сохраняем полный объект узла с его SSD
|
||||
};
|
||||
|
||||
const handleBackFromUnit = () => {
|
||||
@ -209,21 +214,23 @@ const QuickDetailSection: React.FC<QuickDetailSectionProps> = ({
|
||||
|
||||
// Если выбран узел для детального просмотра, показываем UnitDetailsSection
|
||||
if (selectedUnit) {
|
||||
const unitSsd = selectedUnit.ssd || ssd;
|
||||
// ИСПРАВЛЕНИЕ: Используем SSD узла из API ответа, а не родительский SSD
|
||||
// API Laximo возвращает для каждого узла свой собственный SSD
|
||||
console.log('🔍 QuickDetailSection передает в UnitDetailsSection:', {
|
||||
unitSsd: unitSsd ? `${unitSsd.substring(0, 50)}...` : 'отсутствует',
|
||||
unitSsdLength: unitSsd?.length,
|
||||
parentSsd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
parentSsdLength: ssd?.length,
|
||||
selectedUnitSsd: selectedUnit.ssd ? `${selectedUnit.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
fallbackSsd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
selectedUnitSsdLength: selectedUnit.ssd?.length,
|
||||
unitId: selectedUnit.unitid,
|
||||
unitName: selectedUnit.name
|
||||
unitName: selectedUnit.name,
|
||||
note: 'Используем SSD УЗЛА из API ответа'
|
||||
});
|
||||
|
||||
return (
|
||||
<UnitDetailsSection
|
||||
catalogCode={catalogCode}
|
||||
vehicleId={vehicleId}
|
||||
ssd={unitSsd} // Используем SSD узла
|
||||
ssd={selectedUnit.ssd || ssd} // Используем SSD узла, fallback на родительский SSD
|
||||
unitId={selectedUnit.unitid}
|
||||
unitName={selectedUnit.name}
|
||||
onBack={handleBackFromUnit}
|
||||
|
@ -42,7 +42,8 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
|
||||
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: ssd?.length,
|
||||
unitId,
|
||||
unitName
|
||||
unitName,
|
||||
note: 'Используем SSD узла для API запросов'
|
||||
});
|
||||
|
||||
const { data: unitInfoData, loading: unitInfoLoading, error: unitInfoError } = useQuery<{ laximoUnitInfo: LaximoUnitInfo }>(
|
||||
@ -52,11 +53,11 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd: ssd || ''
|
||||
ssd
|
||||
},
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
|
||||
errorPolicy: 'all',
|
||||
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
|
||||
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуальных данных
|
||||
notifyOnNetworkStatusChange: true
|
||||
}
|
||||
);
|
||||
@ -76,9 +77,9 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd: ssd || ''
|
||||
ssd
|
||||
},
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
|
||||
errorPolicy: 'all',
|
||||
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
|
||||
notifyOnNetworkStatusChange: true
|
||||
@ -100,9 +101,9 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd: ssd || ''
|
||||
ssd
|
||||
},
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
|
||||
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
|
||||
errorPolicy: 'all',
|
||||
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
|
||||
notifyOnNetworkStatusChange: true
|
||||
|
@ -68,49 +68,49 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
|
||||
Найдено автомобилей: {results.length}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-wrap flex-1 gap-5 size-full max-md:max-w-full">
|
||||
{results.map((vehicle, index) => (
|
||||
<div
|
||||
key={`${vehicle.vehicleid}-${index}`}
|
||||
className="bg-white rounded-lg shadow-md border border-gray-200 p-4 hover:shadow-lg transition-shadow cursor-pointer"
|
||||
className="flex flex-col flex-1 shrink p-8 bg-white rounded-lg border border-solid basis-0 border-stone-300 max-w-[504px] md:min-w-[370px] sm:min-w-[340px] min-w-[200px] max-md:px-5 cursor-pointer transition-shadow hover:shadow-lg"
|
||||
onClick={() => handleSelectVehicle(vehicle)}
|
||||
>
|
||||
{/* Заголовок автомобиля */}
|
||||
<div className="mb-3">
|
||||
<h4 className="text-lg font-semibold text-blue-600 mb-1">
|
||||
<div className="">
|
||||
<h4 className="text-lg font-semibold text-red-600 mb-1 truncate">
|
||||
{vehicle.name || `${vehicle.brand} ${vehicle.model}`}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
</h4>
|
||||
{/* <p className="text-sm text-gray-500 truncate">
|
||||
{vehicle.modification} ({vehicle.year})
|
||||
</p>
|
||||
</p> */}
|
||||
</div>
|
||||
|
||||
{/* Основные характеристики */}
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Основные характеристики</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Основные характеристики</h5>
|
||||
{renderAttribute('Марка', vehicle.brand)}
|
||||
{renderAttribute('Модель', vehicle.model)}
|
||||
{renderAttribute('Двигатель', vehicle.engine)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Все атрибуты из API */}
|
||||
{vehicle.attributes && vehicle.attributes.length > 0 && (
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Дополнительные характеристики</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Дополнительные характеристики</h5>
|
||||
{vehicle.attributes.map((attr, attrIndex) => (
|
||||
<div key={attrIndex} className="flex justify-between py-1 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600 font-medium">{attr.name || attr.key}:</span>
|
||||
<span className="text-sm text-gray-900">{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Технические характеристики (fallback для старых данных) */}
|
||||
{(!vehicle.attributes || vehicle.attributes.length === 0) && (
|
||||
<>
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Дополнительные характеристики</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Дополнительные характеристики</h5>
|
||||
{renderAttribute('Год', vehicle.year)}
|
||||
{renderAttribute('Кузов', vehicle.bodytype)}
|
||||
{renderAttribute('Трансмиссия', vehicle.transmission)}
|
||||
@ -123,7 +123,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Технические характеристики</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Технические характеристики</h5>
|
||||
{renderAttribute('Информация о двигателе', vehicle.engine_info)}
|
||||
{renderAttribute('Номер двигателя', vehicle.engineno)}
|
||||
{renderAttribute('Дата производства', vehicle.date)}
|
||||
@ -133,7 +133,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Даты и периоды</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Даты и периоды</h5>
|
||||
{renderAttribute('Дата с', vehicle.datefrom)}
|
||||
{renderAttribute('Дата по', vehicle.dateto)}
|
||||
{renderAttribute('Модельный год с', vehicle.modelyearfrom)}
|
||||
@ -143,7 +143,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
|
||||
{/* Опции и описание */}
|
||||
{(vehicle.options || vehicle.description || vehicle.notes) && (
|
||||
<div className="space-y-1 mb-4">
|
||||
<h5 className="text-sm font-semibold text-gray-700 mb-2">Опции и описание</h5>
|
||||
<h5 className="text-base font-semibold text-gray-900 mb-2">Опции и описание</h5>
|
||||
{renderAttribute('Опции', vehicle.options)}
|
||||
{renderAttribute('Описание', vehicle.description)}
|
||||
{renderAttribute('Примечания', vehicle.notes)}
|
||||
@ -153,25 +153,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
|
||||
)}
|
||||
|
||||
{/* Системная информация */}
|
||||
<div className="mt-4 pt-3 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>ID: {vehicle.vehicleid}</div>
|
||||
{vehicle.catalog && <div>Каталог: {vehicle.catalog}</div>}
|
||||
{vehicle.ssd && (
|
||||
<div>SSD: {vehicle.ssd.length > 50 ? `${vehicle.ssd.substring(0, 50)}...` : vehicle.ssd}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug информация (только в development) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-4 p-2 bg-gray-100 rounded text-xs">
|
||||
<div className="font-semibold text-gray-700 mb-1">Debug Info:</div>
|
||||
<pre className="text-gray-600 whitespace-pre-wrap">
|
||||
{JSON.stringify(vehicle, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -26,6 +26,11 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
|
||||
const [getWizard2] = useLazyQuery(GET_LAXIMO_WIZARD2, {
|
||||
onCompleted: (data) => {
|
||||
if (data.laximoWizard2) {
|
||||
console.log('🔄 Wizard обновлен:', {
|
||||
steps: data.laximoWizard2.length,
|
||||
selectedParams: Object.keys(selectedParams).length,
|
||||
currentSsd
|
||||
});
|
||||
setWizardSteps(data.laximoWizard2);
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -76,18 +81,28 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
|
||||
|
||||
// --- Автовыбор единственного варианта для всех шагов ---
|
||||
React.useEffect(() => {
|
||||
// Предотвращаем автовыбор во время загрузки
|
||||
if (isLoading) return;
|
||||
|
||||
wizardSteps.forEach(step => {
|
||||
const options = step.options || [];
|
||||
const selectedKey = selectedParams[step.conditionid]?.key || (step.determined ? options.find(o => o.value === step.value)?.key : '');
|
||||
if (options.length === 1 && selectedKey !== options[0].key) {
|
||||
|
||||
// Автовыбираем только если есть единственный вариант и он еще не выбран
|
||||
if (options.length === 1 && selectedKey !== options[0].key && !selectedParams[step.conditionid]) {
|
||||
handleParamSelect(step, options[0].key, options[0].value);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
}, [wizardSteps, selectedParams]);
|
||||
}, [wizardSteps, selectedParams, isLoading]);
|
||||
|
||||
// Обработка выбора параметра
|
||||
const handleParamSelect = async (step: LaximoWizardStep, optionKey: string, optionValue: string) => {
|
||||
// Проверяем, не выбран ли уже этот параметр
|
||||
if (selectedParams[step.conditionid]?.key === optionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
@ -118,6 +133,13 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
|
||||
|
||||
// Сброс параметра
|
||||
const handleParamReset = async (step: LaximoWizardStep) => {
|
||||
console.log('🔄 Сброс параметра:', {
|
||||
stepName: step.name,
|
||||
conditionId: step.conditionid,
|
||||
currentSsd,
|
||||
selectedParamsBefore: Object.keys(selectedParams)
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
@ -126,8 +148,33 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
|
||||
delete newSelectedParams[step.conditionid];
|
||||
setSelectedParams(newSelectedParams);
|
||||
|
||||
// Используем SSD для сброса параметра, если он есть
|
||||
const resetSsd = step.ssd || '';
|
||||
// Находим правильный SSD для сброса этого параметра
|
||||
// Нужно найти SSD, который соответствует состоянию до выбора этого параметра
|
||||
let resetSsd = '';
|
||||
|
||||
// Ищем среди шагов wizard тот, который имеет правильный SSD для восстановления
|
||||
const currentStepIndex = wizardSteps.findIndex(s => s.conditionid === step.conditionid);
|
||||
|
||||
// Если есть предыдущие шаги с выбранными параметрами, используем их SSD
|
||||
for (let i = currentStepIndex - 1; i >= 0; i--) {
|
||||
const prevStep = wizardSteps[i];
|
||||
if (newSelectedParams[prevStep.conditionid]) {
|
||||
resetSsd = newSelectedParams[prevStep.conditionid].key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли предыдущий SSD, используем step.ssd или пустую строку
|
||||
if (!resetSsd) {
|
||||
resetSsd = step.ssd || '';
|
||||
}
|
||||
|
||||
console.log('🔄 Новый SSD для сброса:', {
|
||||
resetSsd,
|
||||
selectedParamsAfter: Object.keys(newSelectedParams),
|
||||
stepSsd: step.ssd
|
||||
});
|
||||
|
||||
setCurrentSsd(resetSsd);
|
||||
|
||||
try {
|
||||
@ -325,36 +372,36 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка поиска автомобилей */}
|
||||
{!isLoading && canListVehicles && showSearchButton && (
|
||||
<div className="pt-4 border-t">
|
||||
{/* Информация о недостаточности параметров и кнопка поиска */}
|
||||
{!isLoading && wizardSteps.length > 0 && (
|
||||
<div className="flex flex-row gap-4 items-center w-full mx-auto max-sm:flex-col max-sm:items-stretch">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleFindVehicles();
|
||||
setShowSearchButton(false);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto px-8 py-3 bg-red-600 !text-white font-medium rounded-lg shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
disabled={!canListVehicles || isLoading}
|
||||
className="w-full sm:w-auto px-8 py-3 bg-red-600 !text-white font-medium rounded-lg shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center max-sm:w-full"
|
||||
style={{ minWidth: 180 }}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Найти автомобили
|
||||
Найти
|
||||
</button>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Определено параметров: {wizardSteps.filter(s => s.determined).length} из {wizardSteps.length}
|
||||
<div
|
||||
layer-name="Выберите больше параметров для поиска автомобилей"
|
||||
className="box-border inline-flex gap-5 items-center px-10 py-4 rounded-xl bg-slate-50 h-[52px] max-md:px-8 max-md:py-3.5 max-md:w-full max-md:h-auto max-md:max-w-[524px] max-md:min-h-[52px] max-sm:gap-3 max-sm:px-5 max-sm:py-3 max-sm:w-full max-sm:rounded-lg max-sm:justify-center"
|
||||
>
|
||||
<div>
|
||||
<img src="/images/info.svg" alt="info" style={{ width: 18, height: 20, flexShrink: 0 }} />
|
||||
</div>
|
||||
<div
|
||||
layer-name="Выберите больше параметров для поиска автомобилей"
|
||||
className="relative text-base font-medium leading-5 text-center text-gray-950 max-md:text-sm max-sm:text-sm max-sm:leading-4 max-sm:text-center"
|
||||
>
|
||||
Выберите больше параметров для поиска автомобилей
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о недостаточности параметров */}
|
||||
{!isLoading && !canListVehicles && wizardSteps.length > 0 && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-blue-800 text-sm">
|
||||
Выберите больше параметров для поиска автомобилей
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
import CartIcon from "../CartIcon";
|
||||
|
||||
interface ProductBuyBlockProps {
|
||||
offer?: any;
|
||||
@ -37,7 +38,7 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
||||
}
|
||||
|
||||
// Добавляем товар в корзину
|
||||
addItem({
|
||||
const result = await addItem({
|
||||
productId: offer.id ? String(offer.id) : undefined,
|
||||
offerKey: offer.offerKey || undefined,
|
||||
name: offer.name || `${offer.brand} ${offer.articleNumber}`,
|
||||
@ -45,6 +46,7 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
||||
price: offer.price,
|
||||
currency: 'RUB',
|
||||
quantity: quantity,
|
||||
stock: offer.quantity, // передаем информацию о наличии
|
||||
image: offer.image || undefined,
|
||||
brand: offer.brand,
|
||||
article: offer.articleNumber,
|
||||
@ -53,17 +55,22 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
||||
isExternal: offer.type === 'external'
|
||||
});
|
||||
|
||||
// Показываем успешный тоастер
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold">Товар добавлен в корзину!</div>
|
||||
<div className="text-sm text-gray-600">{offer.name || `${offer.brand} ${offer.articleNumber}`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: '🛒',
|
||||
}
|
||||
);
|
||||
if (result.success) {
|
||||
// Показываем успешный тоастер
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{offer.name || `${offer.brand} ${offer.articleNumber}`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: <CartIcon size={20} color="#fff" />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Показываем ошибку
|
||||
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления в корзину:', error);
|
||||
toast.error('Ошибка при добавлении товара в корзину');
|
||||
|
@ -15,8 +15,10 @@ const DEFAULT_MAX = 32000;
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));
|
||||
|
||||
const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max = DEFAULT_MAX, isMobile = false, value = null, onChange }) => {
|
||||
const [from, setFrom] = useState(value ? value[0] : min);
|
||||
const [to, setTo] = useState(value ? value[1] : max);
|
||||
const [from, setFrom] = useState<string>(value ? String(value[0]) : String(min));
|
||||
const [to, setTo] = useState<string>(value ? String(value[1]) : String(max));
|
||||
const [confirmedFrom, setConfirmedFrom] = useState<number>(value ? value[0] : min);
|
||||
const [confirmedTo, setConfirmedTo] = useState<number>(value ? value[1] : max);
|
||||
const [dragging, setDragging] = useState<null | "from" | "to">(null);
|
||||
const [trackWidth, setTrackWidth] = useState(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
@ -25,11 +27,15 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
// Обновляем локальное состояние при изменении внешнего значения
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setFrom(value[0]);
|
||||
setTo(value[1]);
|
||||
setFrom(String(value[0]));
|
||||
setTo(String(value[1]));
|
||||
setConfirmedFrom(value[0]);
|
||||
setConfirmedTo(value[1]);
|
||||
} else {
|
||||
setFrom(min);
|
||||
setTo(max);
|
||||
setFrom(String(min));
|
||||
setTo(String(max));
|
||||
setConfirmedFrom(min);
|
||||
setConfirmedTo(max);
|
||||
}
|
||||
}, [value, min, max]);
|
||||
|
||||
@ -61,15 +67,15 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
x = clamp(x, 0, trackWidth);
|
||||
const value = clamp(pxToValue(x), min, max);
|
||||
if (dragging === "from") {
|
||||
setFrom(v => clamp(Math.min(value, to), min, to));
|
||||
setFrom(v => String(clamp(Math.min(value, Number(to)), min, Number(to))));
|
||||
} else {
|
||||
setTo(v => clamp(Math.max(value, from), from, max));
|
||||
setTo(v => String(clamp(Math.max(value, Number(from)), Number(from), max)));
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
setDragging(null);
|
||||
if (onChange) {
|
||||
onChange([from, to]);
|
||||
onChange([Number(from), Number(to)]);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
@ -82,25 +88,48 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
|
||||
// Input handlers
|
||||
const handleFromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = Number(e.target.value.replace(/\D/g, ""));
|
||||
if (isNaN(v)) v = min;
|
||||
setFrom(clamp(Math.min(v, to), min, to));
|
||||
let v = e.target.value.replace(/\D/g, "");
|
||||
setFrom(v);
|
||||
};
|
||||
const handleToInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = Number(e.target.value.replace(/\D/g, ""));
|
||||
if (isNaN(v)) v = max;
|
||||
setTo(clamp(Math.max(v, from), from, max));
|
||||
let v = e.target.value.replace(/\D/g, "");
|
||||
setTo(v);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (onChange) {
|
||||
onChange([from, to]);
|
||||
const handleFromBlur = () => {
|
||||
let v = Number(from);
|
||||
if (isNaN(v) || v < min) v = min;
|
||||
// если больше max — оставлять как есть
|
||||
setFrom(String(v));
|
||||
if (onChange) onChange([v, to === "" ? max : Number(to)]);
|
||||
setConfirmedFrom(v);
|
||||
};
|
||||
|
||||
const handleToBlur = () => {
|
||||
let v = Number(to);
|
||||
if (isNaN(v) || v < min) v = min;
|
||||
if (v > max) v = max;
|
||||
setTo(String(v));
|
||||
if (onChange) onChange([from === "" ? min : Number(from), v]);
|
||||
setConfirmedTo(v);
|
||||
};
|
||||
|
||||
const handleFromKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleFromBlur();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
const handleToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleToBlur();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
// px позиции для точек
|
||||
const pxFrom = valueToPx(from);
|
||||
const pxTo = valueToPx(to);
|
||||
const pxFrom = valueToPx(dragging ? Number(from) : confirmedFrom);
|
||||
const pxTo = valueToPx(dragging ? Number(to) : confirmedTo);
|
||||
|
||||
// Мобильная версия - без dropdown
|
||||
if (isMobile) {
|
||||
@ -124,7 +153,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
id="from"
|
||||
value={from}
|
||||
onChange={handleFromInput}
|
||||
onBlur={handleInputBlur}
|
||||
onBlur={handleFromBlur}
|
||||
onKeyDown={handleFromKeyDown}
|
||||
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
@ -139,7 +169,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
id="to"
|
||||
value={to}
|
||||
onChange={handleToInput}
|
||||
onBlur={handleInputBlur}
|
||||
onBlur={handleToBlur}
|
||||
onKeyDown={handleToKeyDown}
|
||||
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
@ -214,7 +245,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
id="from"
|
||||
value={from}
|
||||
onChange={handleFromInput}
|
||||
onBlur={handleInputBlur}
|
||||
onBlur={handleFromBlur}
|
||||
onKeyDown={handleFromKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="div-block-5">
|
||||
@ -228,7 +260,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
id="to"
|
||||
value={to}
|
||||
onChange={handleToInput}
|
||||
onBlur={handleInputBlur}
|
||||
onBlur={handleToBlur}
|
||||
onKeyDown={handleToKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,4 +1,26 @@
|
||||
import React from "react";
|
||||
import BestPriceItem from "../BestPriceItem";
|
||||
|
||||
// Моковые данные для лучших цен
|
||||
const bestPriceItems = [
|
||||
{
|
||||
image: "images/162615.webp",
|
||||
discount: "-35%",
|
||||
price: "от 17 087 ₽",
|
||||
oldPrice: "22 347 ₽",
|
||||
title: 'Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60',
|
||||
brand: "TYUMEN BATTERY",
|
||||
},
|
||||
// ...добавьте еще 7 карточек для примера
|
||||
...Array(7).fill(0).map((_, i) => ({
|
||||
image: "images/162615.webp",
|
||||
discount: "-35%",
|
||||
price: `от ${(17087 + i * 1000).toLocaleString('ru-RU')} ₽`,
|
||||
oldPrice: `${(22347 + i * 1000).toLocaleString('ru-RU')} ₽`,
|
||||
title: `Товар №${i + 2}`,
|
||||
brand: `Бренд ${i + 2}`,
|
||||
}))
|
||||
];
|
||||
|
||||
const BestPriceSection: React.FC = () => (
|
||||
<section className="main">
|
||||
@ -10,29 +32,8 @@ const BestPriceSection: React.FC = () => (
|
||||
<a href="#" className="button-24 w-button">Показать все</a>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-121">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div className="w-layout-vflex bestpriceitem" key={i}>
|
||||
<div className="favcardcat">
|
||||
<div className="icon-setting w-embed"><svg width="currenWidth" 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" stroke="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<div className="imgitembp"><img width="auto" height="auto" alt="" src="images/162615.webp" loading="lazy" srcSet="images/162615-p-500.webp 500w, images/162615.webp 600w" sizes="(max-width: 600px) 100vw, 600px" className="image-5" />
|
||||
<div className="saletagbp">-35%</div>
|
||||
</div>
|
||||
<div className="div-block-3">
|
||||
<div className="w-layout-hflex pricecartbp">
|
||||
<div className="actualprice">от 17 087 ₽</div>
|
||||
<div className="oldpricebp">22 347 ₽</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-120">
|
||||
<div className="nameitembp">Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60</div>
|
||||
<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"></path></svg></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{bestPriceItems.map((item, i) => (
|
||||
<BestPriceItem key={i} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,27 +1,28 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const IndexTopMenuNav = () => (
|
||||
<section className="topmenub">
|
||||
<div className="w-layout-blockcontainer tb nav w-container">
|
||||
<div className="w-layout-hflex flex-block-107">
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
<Link href="/about" className="link-block-8 w-inline-block">
|
||||
<div>О компании</div>
|
||||
</a>
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
</Link>
|
||||
<Link href="/payments-method" className="link-block-8 w-inline-block">
|
||||
<div>Оплата и доставка</div>
|
||||
</a>
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
</Link>
|
||||
<Link href="/" className="link-block-8 w-inline-block">
|
||||
<div>Гарантия и возврат</div>
|
||||
</a>
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
</Link>
|
||||
<Link href="/payments-method" className="link-block-8 w-inline-block">
|
||||
<div>Покупателям</div>
|
||||
</a>
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
</Link>
|
||||
<Link href="/wholesale" className="link-block-8 w-inline-block">
|
||||
<div>Оптовым клиентам</div>
|
||||
</a>
|
||||
<a href="#" className="link-block-8 w-inline-block">
|
||||
</Link>
|
||||
<Link href="/contacts" className="link-block-8 w-inline-block">
|
||||
<div>Контакты</div>
|
||||
</a>
|
||||
</Link>
|
||||
<a href="#" className="link-block-8 green w-inline-block">
|
||||
<div>Новые поступления товаров</div>
|
||||
</a>
|
||||
|
@ -1,4 +1,40 @@
|
||||
import React from "react";
|
||||
import ArticleCard from "../ArticleCard";
|
||||
import { PartsAPIArticle } from "@/types/partsapi";
|
||||
|
||||
// Моковые данные для новых поступлений
|
||||
const newArrivalsArticles: PartsAPIArticle[] = [
|
||||
{
|
||||
artId: "1",
|
||||
artArticleNr: "6CT-60L",
|
||||
artSupBrand: "TYUMEN BATTERY",
|
||||
supBrand: "TYUMEN BATTERY",
|
||||
supId: 1,
|
||||
productGroup: "Аккумуляторная батарея",
|
||||
ptId: 1,
|
||||
},
|
||||
{
|
||||
artId: "2",
|
||||
artArticleNr: "A0001",
|
||||
artSupBrand: "Borsehung",
|
||||
supBrand: "Borsehung",
|
||||
supId: 2,
|
||||
productGroup: "Масляный фильтр",
|
||||
ptId: 2,
|
||||
},
|
||||
// ...добавьте еще 6 статей для примера
|
||||
...Array(6).fill(0).map((_, i) => ({
|
||||
artId: `${i+3}`,
|
||||
artArticleNr: `ART${i+3}`,
|
||||
artSupBrand: `Brand${i+3}`,
|
||||
supBrand: `Brand${i+3}`,
|
||||
supId: i+3,
|
||||
productGroup: `Product Group ${i+3}`,
|
||||
ptId: i+3,
|
||||
}))
|
||||
];
|
||||
|
||||
const imagePath = "images/162615.webp";
|
||||
|
||||
const NewArrivalsSection: React.FC = () => (
|
||||
<section className="main">
|
||||
@ -8,42 +44,8 @@ const NewArrivalsSection: React.FC = () => (
|
||||
<h2 className="heading-4">Новое поступление</h2>
|
||||
</div>
|
||||
<div className="w-layout-hflex core-product-search">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div className="w-layout-vflex flex-block-15-copy" key={i}>
|
||||
<div className="favcardcat">
|
||||
<div className="icon-setting w-embed"><svg width="currenWidth" 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" stroke="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<div className="div-block-4">
|
||||
<img
|
||||
src="images/162615.webp"
|
||||
loading="lazy"
|
||||
width="auto"
|
||||
height="auto"
|
||||
alt="Новое поступление: Аккумуляторная батарея TYUMEN BATTERY"
|
||||
srcSet="images/162615-p-500.webp 500w, images/162615.webp 600w"
|
||||
sizes="(max-width: 600px) 100vw, 600px"
|
||||
className="image-5"
|
||||
/>
|
||||
<div className="text-block-7">-35%</div>
|
||||
</div>
|
||||
<div className="div-block-3">
|
||||
<div className="w-layout-hflex flex-block-16">
|
||||
<div className="text-block-8">от 17 087 ₽</div>
|
||||
<div className="text-block-9">22 347 ₽</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-122">
|
||||
<div className="w-layout-vflex">
|
||||
<div className="text-block-10">Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60</div>
|
||||
<div className="text-block-11">Borsehung</div>
|
||||
</div>
|
||||
<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"></path></svg></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{newArrivalsArticles.map((article, i) => (
|
||||
<ArticleCard key={article.artId || i} article={{ ...article, artId: article.artId }} index={i} image={imagePath} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,12 +3,12 @@ import React from "react";
|
||||
const SupportVinSection: React.FC = () => (
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container-copy w-container">
|
||||
<img
|
||||
src="images/support_img.png"
|
||||
loading="lazy"
|
||||
alt="Поддержка: помощь с VIN-запросом"
|
||||
className="image-27"
|
||||
/>
|
||||
<img
|
||||
src="images/support_img.png"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
className="image-27"
|
||||
/>
|
||||
<div className="div-block-11">
|
||||
<div className="w-layout-vflex flex-block-30">
|
||||
<h3 className="supportheading">МЫ ВСЕГДА РАДЫ ПОМОЧЬ</h3>
|
||||
|
@ -1,4 +1,38 @@
|
||||
import React from "react";
|
||||
import ArticleCard from "../ArticleCard";
|
||||
import { PartsAPIArticle } from "@/types/partsapi";
|
||||
|
||||
// Моковые данные для топ продаж
|
||||
const topSalesArticles: PartsAPIArticle[] = [
|
||||
{
|
||||
artId: "1",
|
||||
artArticleNr: "6CT-60L",
|
||||
artSupBrand: "TYUMEN BATTERY",
|
||||
supBrand: "TYUMEN BATTERY",
|
||||
supId: 1,
|
||||
productGroup: "Аккумуляторная батарея",
|
||||
ptId: 1,
|
||||
},
|
||||
{
|
||||
artId: "2",
|
||||
artArticleNr: "A0001",
|
||||
artSupBrand: "Borsehung",
|
||||
supBrand: "Borsehung",
|
||||
supId: 2,
|
||||
productGroup: "Масляный фильтр",
|
||||
ptId: 2,
|
||||
},
|
||||
// ...добавьте еще 6 статей для примера
|
||||
...Array(6).fill(0).map((_, i) => ({
|
||||
artId: `${i+3}`,
|
||||
artArticleNr: `ART${i+3}`,
|
||||
artSupBrand: `Brand${i+3}`,
|
||||
supBrand: `Brand${i+3}`,
|
||||
supId: i+3,
|
||||
productGroup: `Product Group ${i+3}`,
|
||||
ptId: i+3,
|
||||
}))
|
||||
];
|
||||
|
||||
const TopSalesSection: React.FC = () => (
|
||||
<section className="main">
|
||||
@ -8,32 +42,8 @@ const TopSalesSection: React.FC = () => (
|
||||
<h2 className="heading-4">Топ продаж</h2>
|
||||
</div>
|
||||
<div className="w-layout-hflex core-product-search">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div className="w-layout-vflex flex-block-15-copy" key={i}>
|
||||
<div className="favcardcat">
|
||||
<div className="icon-setting w-embed"><svg width="currenWidth" 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" stroke="currentColor"></path></svg></div>
|
||||
</div>
|
||||
<div className="div-block-4"><img src="images/162615.webp" loading="lazy" width="auto" height="auto" alt="" srcSet="images/162615-p-500.webp 500w, images/162615.webp 600w" sizes="(max-width: 600px) 100vw, 600px" className="image-5" />
|
||||
<div className="text-block-7">-35%</div>
|
||||
</div>
|
||||
<div className="div-block-3">
|
||||
<div className="w-layout-hflex flex-block-16">
|
||||
<div className="text-block-8">от 17 087 ₽</div>
|
||||
<div className="text-block-9">22 347 ₽</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-122">
|
||||
<div className="w-layout-vflex">
|
||||
<div className="text-block-10">Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60</div>
|
||||
<div className="text-block-11">Borsehung</div>
|
||||
</div>
|
||||
<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"></path></svg></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{topSalesArticles.map((article, i) => (
|
||||
<ArticleCard key={article.artId || i} article={article} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
243
src/components/profile/CookieSettings.tsx
Normal file
243
src/components/profile/CookieSettings.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
getCookiePreferences,
|
||||
CookiePreferences,
|
||||
initializeAnalytics,
|
||||
initializeMarketing,
|
||||
resetCookieConsent
|
||||
} from '@/lib/cookie-utils';
|
||||
|
||||
const CookieSettings: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Загружаем текущие настройки
|
||||
const currentPreferences = getCookiePreferences();
|
||||
if (currentPreferences) {
|
||||
setPreferences(currentPreferences);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const togglePreference = (key: keyof CookiePreferences) => {
|
||||
if (key === 'necessary') return; // Необходимые cookies нельзя отключить
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
|
||||
try {
|
||||
// Сохраняем настройки
|
||||
localStorage.setItem('cookieConsent', 'configured');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify(preferences));
|
||||
|
||||
// Инициализируем сервисы согласно настройкам
|
||||
if (preferences.analytics) {
|
||||
initializeAnalytics();
|
||||
}
|
||||
if (preferences.marketing) {
|
||||
initializeMarketing();
|
||||
}
|
||||
|
||||
setSaveMessage('Настройки успешно сохранены');
|
||||
|
||||
// Убираем сообщение через 3 секунды
|
||||
setTimeout(() => {
|
||||
setSaveMessage(null);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
setSaveMessage('Ошибка при сохранении настроек');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetCookieConsent();
|
||||
setPreferences({
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
});
|
||||
setSaveMessage('Настройки сброшены. Перезагрузите страницу для повторного отображения уведомления о cookies.');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-8 max-md:px-5">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-950 mb-2">
|
||||
Настройки файлов cookie
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Управляйте тем, как мы используем файлы cookie на нашем сайте.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
saveMessage.includes('Ошибка')
|
||||
? 'bg-red-50 border border-red-200 text-red-800'
|
||||
: 'bg-green-50 border border-green-200 text-green-800'
|
||||
}`}>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Необходимые cookies */}
|
||||
<div className="flex items-start justify-between p-6 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold text-gray-950">Необходимые cookies</h3>
|
||||
<span className="text-xs px-2 py-1 bg-gray-200 text-gray-600 rounded">
|
||||
Обязательные
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Эти файлы cookie необходимы для работы сайта и не могут быть отключены.
|
||||
Они обеспечивают базовую функциональность, включая корзину покупок, авторизацию и безопасность.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
Включает: сессии, корзина, авторизация, безопасность
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-6">
|
||||
<div className="w-12 h-6 bg-red-600 rounded-full flex items-center justify-end px-1">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Аналитические cookies */}
|
||||
<div className="flex items-start justify-between p-6 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Аналитические cookies</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Помогают нам понять, как посетители взаимодействуют с сайтом, чтобы улучшить его работу и пользовательский опыт.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
Включает: Google Analytics, статистика посещений, анализ поведения
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-6">
|
||||
<button
|
||||
onClick={() => togglePreference('analytics')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.analytics ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Маркетинговые cookies */}
|
||||
<div className="flex items-start justify-between p-6 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Маркетинговые cookies</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Используются для отслеживания посетителей и показа релевантной рекламы.
|
||||
Помогают измерить эффективность рекламных кампаний.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
Включает: рекламные пиксели, ретаргетинг, социальные сети
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-6">
|
||||
<button
|
||||
onClick={() => togglePreference('marketing')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.marketing ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Функциональные cookies */}
|
||||
<div className="flex items-start justify-between p-6 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Функциональные cookies</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Обеспечивают расширенную функциональность и персонализацию сайта,
|
||||
включая предпочтения и настройки пользователя.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
Включает: языковые настройки, персонализация, чат-боты
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-6">
|
||||
<button
|
||||
onClick={() => togglePreference('functional')}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
|
||||
preferences.functional ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
|
||||
} px-1`}
|
||||
>
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[140px]"
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить настройки'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[140px]"
|
||||
>
|
||||
Сбросить настройки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="text-blue-600">
|
||||
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15h-2v-2h2v2zm0-4h-2V5h2v6z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 mb-1">Информация о cookies</h4>
|
||||
<p className="text-sm text-blue-800">
|
||||
Изменения настроек cookies вступают в силу немедленно. Некоторые функции сайта могут работать некорректно при отключении определенных типов cookies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieSettings;
|
@ -69,7 +69,7 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
|
||||
{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] ${
|
||||
className={`flex flex-1 shrink gap-5 items-center h-full text-center rounded-xl basis-12 min-w-[200px] ${
|
||||
activeTab === tab
|
||||
? "text-white"
|
||||
: "bg-slate-200 text-gray-950"
|
||||
@ -78,7 +78,7 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
|
||||
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 ${
|
||||
className={`flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 min-w-[200px] max-md:px-5 ${
|
||||
activeTab === tab
|
||||
? "text-white bg-red-600"
|
||||
: "bg-slate-200 text-gray-950"
|
||||
@ -89,12 +89,12 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="relative w-[240px] max-w-full max-sm:w-full"
|
||||
className="relative w-[300px] max-w-full max-sm: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"
|
||||
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 min-w-[200px]"
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="truncate">{selectedManufacturer}</span>
|
||||
@ -111,21 +111,21 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
|
||||
</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 max-h-60 overflow-y-auto">
|
||||
<ul className="absolute px-0 pb-2 pl-0 list-none left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full max-h-60 overflow-y-auto dropdown-scroll-invisible">
|
||||
{manufacturers.length === 0 ? (
|
||||
<li className="px-6 py-4 text-gray-400 text-center">
|
||||
<li className="py-2 text-xs text-gray-400 text-center">
|
||||
Нет данных
|
||||
</li>
|
||||
) : (
|
||||
manufacturers.map((manufacturer) => (
|
||||
<li
|
||||
key={manufacturer}
|
||||
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 transition-colors ${manufacturer === selectedManufacturer ? 'bg-blue-50 font-semibold text-blue-600' : ''}`}
|
||||
className={`py-2 px-5 text-sm cursor-pointer hover:bg-blue-100 transition-colors ${manufacturer === selectedManufacturer ? 'bg-blue-50 text-red-600 font-normal' : 'text-neutral-500 font-medium'}`}
|
||||
onMouseDown={() => handleManufacturerSelect(manufacturer)}
|
||||
>
|
||||
{manufacturer}
|
||||
{manufacturer !== "Все" && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
({historyItems.filter(item =>
|
||||
item.brand === manufacturer || item.vehicleInfo?.brand === manufacturer
|
||||
).length})
|
||||
|
@ -43,30 +43,72 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
|
||||
const router = useRouter();
|
||||
|
||||
// Получаем инфо об узле (для картинки)
|
||||
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_INFO запрос:', {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: ssd?.length,
|
||||
skipCondition: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === ''
|
||||
});
|
||||
|
||||
const { data: unitInfoData, loading: unitInfoLoading, error: unitInfoError } = useQuery(
|
||||
GET_LAXIMO_UNIT_INFO,
|
||||
{
|
||||
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', unitId: unitId || '', ssd: ssd || '' },
|
||||
skip: !catalogCode || !vehicleId || !unitId,
|
||||
variables: {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd
|
||||
},
|
||||
skip: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '',
|
||||
errorPolicy: 'all',
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем карту координат
|
||||
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_IMAGE_MAP запрос:', {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: ssd?.length,
|
||||
skipCondition: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === ''
|
||||
});
|
||||
|
||||
const { data: imageMapData, loading: imageMapLoading, error: imageMapError } = useQuery(
|
||||
GET_LAXIMO_UNIT_IMAGE_MAP,
|
||||
{
|
||||
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', unitId: unitId || '', ssd: ssd || '' },
|
||||
skip: !catalogCode || !vehicleId || !unitId,
|
||||
variables: {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
unitId,
|
||||
ssd
|
||||
},
|
||||
skip: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '',
|
||||
errorPolicy: 'all',
|
||||
}
|
||||
);
|
||||
|
||||
// Если нет необходимых данных, показываем заглушку
|
||||
if (!catalogCode || !vehicleId || !unitId) {
|
||||
if (!catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '') {
|
||||
console.log('⚠️ KnotIn: отсутствуют необходимые данные:', {
|
||||
catalogCode: !!catalogCode,
|
||||
vehicleId: !!vehicleId,
|
||||
unitId: !!unitId,
|
||||
ssd: !!ssd,
|
||||
ssdValid: ssd ? ssd.trim() !== '' : false
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">Схема узла</div>
|
||||
<div className="text-sm">Выберите узел для отображения схемы</div>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="text-xs text-red-500 mt-2">
|
||||
Debug: catalogCode={catalogCode}, vehicleId={vehicleId}, unitId={unitId}, ssd={ssd ? 'есть' : 'нет'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -75,6 +117,29 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
|
||||
const coordinates = imageMapData?.laximoUnitImageMap?.coordinates || [];
|
||||
const imageUrl = unitInfo?.imageurl ? getImageUrl(unitInfo.imageurl, selectedImageSize) : '';
|
||||
|
||||
// Логируем успешную загрузку данных
|
||||
React.useEffect(() => {
|
||||
if (unitInfo) {
|
||||
console.log('✅ KnotIn: данные узла загружены:', {
|
||||
unitName: unitInfo.name,
|
||||
hasImage: !!unitInfo.imageurl,
|
||||
imageUrl: unitInfo.imageurl,
|
||||
processedImageUrl: imageUrl
|
||||
});
|
||||
}
|
||||
}, [unitInfo, imageUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (coordinates.length > 0) {
|
||||
console.log('✅ KnotIn: координаты карты загружены:', {
|
||||
coordinatesCount: coordinates.length,
|
||||
firstCoordinate: coordinates[0]
|
||||
});
|
||||
} else if (imageMapData) {
|
||||
console.log('⚠️ KnotIn: карта изображений загружена, но координаты пустые:', imageMapData);
|
||||
}
|
||||
}, [coordinates, imageMapData]);
|
||||
|
||||
// Масштабируем точки после загрузки картинки
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
@ -110,13 +175,49 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
|
||||
}, [parts, coordinates]);
|
||||
|
||||
if (unitInfoLoading || imageMapLoading) {
|
||||
console.log('🔄 KnotIn: загрузка данных...', {
|
||||
unitInfoLoading,
|
||||
imageMapLoading,
|
||||
unitInfoError: unitInfoError?.message,
|
||||
imageMapError: imageMapError?.message
|
||||
});
|
||||
return <div className="text-center py-8 text-gray-500">Загружаем схему узла...</div>;
|
||||
}
|
||||
|
||||
if (unitInfoError) {
|
||||
return <div className="text-center py-8 text-red-600">Ошибка загрузки схемы: {unitInfoError.message}</div>;
|
||||
console.error('❌ KnotIn: ошибка загрузки информации об узле:', unitInfoError);
|
||||
return (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
Ошибка загрузки схемы: {unitInfoError.message}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="text-xs mt-2 text-gray-500">
|
||||
GraphQL Error: {JSON.stringify(unitInfoError, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (imageMapError) {
|
||||
console.error('❌ KnotIn: ошибка загрузки карты изображений:', imageMapError);
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return <div className="text-center py-8 text-gray-400">Нет изображения для этого узла</div>;
|
||||
console.log('⚠️ KnotIn: нет URL изображения:', {
|
||||
unitInfo: !!unitInfo,
|
||||
imageurl: unitInfo?.imageurl,
|
||||
unitInfoData: !!unitInfoData
|
||||
});
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Нет изображения для этого узла
|
||||
{process.env.NODE_ENV === 'development' && unitInfo && (
|
||||
<div className="text-xs mt-2 text-gray-500">
|
||||
Debug: unitInfo.imageurl = {unitInfo.imageurl || 'отсутствует'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -137,10 +238,10 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
|
||||
/>
|
||||
{/* Точки/области */}
|
||||
{coordinates.map((coord: any, idx: number) => {
|
||||
const scaledX = coord.x * imageScale.x;
|
||||
const scaledY = coord.y * imageScale.y;
|
||||
const scaledWidth = coord.width * imageScale.x;
|
||||
const scaledHeight = coord.height * imageScale.y;
|
||||
// Кружки всегда 32x32px, центрируем по координате
|
||||
const size = 22;
|
||||
const scaledX = coord.x * imageScale.x - size / 2;
|
||||
const scaledY = coord.y * imageScale.y - size / 2;
|
||||
return (
|
||||
<div
|
||||
key={`coord-${unitId}-${idx}-${coord.x}-${coord.y}`}
|
||||
@ -149,19 +250,29 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handlePointClick(coord.codeonimage);
|
||||
}}
|
||||
className="absolute flex items-center justify-center border-2 border-red-600 bg-white rounded-full cursor-pointer"
|
||||
className="absolute flex items-center justify-center cursor-pointer transition-colors"
|
||||
style={{
|
||||
left: scaledX,
|
||||
top: scaledY,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
width: size,
|
||||
height: size,
|
||||
background: '#B7CAE2',
|
||||
borderRadius: '50%',
|
||||
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
title={coord.codeonimage}
|
||||
onClick={() => handlePointClick(coord.codeonimage)}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = '#EC1C24';
|
||||
(e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = '#B7CAE2';
|
||||
(e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#000';
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-center w-full h-full text-black text-sm font-bold select-none pointer-events-none">
|
||||
<span className="flex items-center justify-center w-full h-full text-black text-sm font-bold select-none pointer-events-none" style={{color: '#000'}}>
|
||||
{coord.codeonimage}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useLazyQuery } from '@apollo/client';
|
||||
import { GET_LAXIMO_CATEGORIES, GET_LAXIMO_QUICK_GROUPS, GET_LAXIMO_UNITS } from '@/lib/graphql/laximo';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface VinCategoryProps {
|
||||
catalogCode?: string;
|
||||
@ -10,18 +11,15 @@ interface VinCategoryProps {
|
||||
activeTab?: 'uzly' | 'manufacturer';
|
||||
onQuickGroupSelect?: (group: any) => void;
|
||||
onCategoryClick?: (e?: React.MouseEvent) => void;
|
||||
openedPath?: string[];
|
||||
setOpenedPath?: (path: string[]) => void;
|
||||
}
|
||||
|
||||
const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd, onNodeSelect, activeTab = 'uzly', onQuickGroupSelect, onCategoryClick }) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<any>(null);
|
||||
const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd, onNodeSelect, activeTab = 'uzly', onQuickGroupSelect, onCategoryClick, openedPath = [], setOpenedPath = () => {} }) => {
|
||||
const router = useRouter();
|
||||
const [unitsByCategory, setUnitsByCategory] = useState<{ [key: string]: any[] }>({});
|
||||
const lastCategoryIdRef = useRef<string | null>(null);
|
||||
|
||||
// Сброс выбранной категории при смене вкладки
|
||||
useEffect(() => {
|
||||
setSelectedCategory(null);
|
||||
}, [activeTab]);
|
||||
|
||||
// Запрос для "Общие" (QuickGroups)
|
||||
const { data: quickGroupsData, loading: quickGroupsLoading, error: quickGroupsError } = useQuery(GET_LAXIMO_QUICK_GROUPS, {
|
||||
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', ssd: ssd || '' },
|
||||
@ -51,49 +49,104 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
|
||||
}
|
||||
});
|
||||
|
||||
const categories = activeTab === 'uzly' ? (quickGroupsData?.laximoQuickGroups || []) : (categoriesData?.laximoCategories || []);
|
||||
// categories теперь зависят от activeTab
|
||||
let categories = activeTab === 'uzly' ? (quickGroupsData?.laximoQuickGroups || []) : (categoriesData?.laximoCategories || []);
|
||||
let selectedCategory: any = null;
|
||||
let currentLevel = 0;
|
||||
let currentList = categories;
|
||||
while (openedPath[currentLevel]) {
|
||||
const found = currentList.find((cat: any) => (cat.quickgroupid || cat.categoryid || cat.id) === openedPath[currentLevel]);
|
||||
if (!found) break;
|
||||
selectedCategory = found;
|
||||
currentList = found.children || [];
|
||||
currentLevel++;
|
||||
}
|
||||
|
||||
const loading = activeTab === 'uzly' ? quickGroupsLoading : categoriesLoading;
|
||||
const error = activeTab === 'uzly' ? quickGroupsError : categoriesError;
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedCategory(null);
|
||||
// Загружаем units для категории если нет children (аналогично VinLeftbar)
|
||||
useEffect(() => {
|
||||
if (selectedCategory && activeTab === 'manufacturer') {
|
||||
const categoryId = selectedCategory.categoryid || selectedCategory.quickgroupid || selectedCategory.id;
|
||||
|
||||
// Если нет children и нет загруженных units - загружаем units
|
||||
if ((!selectedCategory.children || selectedCategory.children.length === 0) &&
|
||||
!unitsByCategory[categoryId]) {
|
||||
console.log('🔄 VinCategory: Загружаем units для категории', categoryId);
|
||||
lastCategoryIdRef.current = categoryId;
|
||||
getUnits({
|
||||
variables: {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
ssd,
|
||||
categoryId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedCategory, activeTab, catalogCode, vehicleId, ssd, getUnits, unitsByCategory]);
|
||||
|
||||
// Функция для обновления openedPath и catpath в URL
|
||||
const updatePath = (newPath: string[]) => {
|
||||
console.log('🔄 VinCategory: updatePath вызван с newPath:', newPath);
|
||||
setOpenedPath(newPath);
|
||||
if (router) {
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: { ...router.query, catpath: newPath.join(',') } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: any) => {
|
||||
// Если передан onCategoryClick, используем его
|
||||
const handleBack = () => {
|
||||
updatePath(openedPath.slice(0, openedPath.length - 1));
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: any, level: number) => {
|
||||
if (onCategoryClick) {
|
||||
onCategoryClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'uzly') {
|
||||
// Логика для вкладки "Общие" (QuickGroups)
|
||||
if (category.children && category.children.length > 0) {
|
||||
setSelectedCategory(category);
|
||||
} else if (category.link && onQuickGroupSelect) {
|
||||
onQuickGroupSelect(category);
|
||||
} else if (onNodeSelect) {
|
||||
onNodeSelect(category);
|
||||
}
|
||||
} else {
|
||||
// Логика для вкладки "От производителя" (Categories)
|
||||
if (category.children && category.children.length > 0) {
|
||||
setSelectedCategory(category);
|
||||
|
||||
const categoryId = category.quickgroupid || category.categoryid || category.id;
|
||||
|
||||
// Если это режим "От производителя", всегда пытаемся войти в категорию
|
||||
if (activeTab === 'manufacturer') {
|
||||
// Проверяем, открыта ли уже эта категория
|
||||
if (openedPath[level] === categoryId) {
|
||||
// Если уже открыта - закрываем
|
||||
updatePath(openedPath.slice(0, level));
|
||||
} else {
|
||||
// Если нет children, грузим units (подкатегории)
|
||||
const categoryId = category.categoryid || category.quickgroupid || category.id;
|
||||
if (!unitsByCategory[categoryId] && catalogCode && vehicleId) {
|
||||
// Если не открыта - открываем (добавляем в path)
|
||||
updatePath([...openedPath.slice(0, level), categoryId]);
|
||||
|
||||
// Если у категории нет children, загружаем units
|
||||
if ((!category.children || category.children.length === 0) && !unitsByCategory[categoryId]) {
|
||||
console.log('🔄 VinCategory: handleCategoryClick загружает units для категории', categoryId);
|
||||
lastCategoryIdRef.current = categoryId;
|
||||
getUnits({
|
||||
getUnits({
|
||||
variables: {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
ssd: ssd || '',
|
||||
ssd,
|
||||
categoryId
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
setSelectedCategory(category);
|
||||
}
|
||||
} else {
|
||||
// Режим "Общие" - используем старую логику
|
||||
if (category.children && category.children.length > 0) {
|
||||
if (openedPath[level] === categoryId) {
|
||||
updatePath(openedPath.slice(0, level));
|
||||
} else {
|
||||
updatePath([...openedPath.slice(0, level), categoryId]);
|
||||
}
|
||||
} else if (category.link && onQuickGroupSelect) {
|
||||
// Для вкладки "Общие" с link=true используем QuickGroup
|
||||
onQuickGroupSelect(category);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -106,7 +159,7 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
|
||||
unitid: subcat.unitid || subcat.categoryid || subcat.quickgroupid || subcat.id
|
||||
});
|
||||
} else {
|
||||
handleCategoryClick(subcat);
|
||||
handleCategoryClick(subcat, 0);
|
||||
}
|
||||
};
|
||||
|
||||
@ -150,7 +203,7 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
|
||||
<div
|
||||
className="div-block-131"
|
||||
key={cat.quickgroupid || cat.categoryid || cat.id || idx}
|
||||
onClick={() => handleCategoryClick(cat)}
|
||||
onClick={() => handleCategoryClick(cat, 0)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="text-block-57">{cat.name}</div>
|
||||
@ -165,32 +218,58 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
|
||||
) : (
|
||||
// Список подкатегорий
|
||||
<>
|
||||
{/* <div className="div-block-131" onClick={handleBack} style={{ cursor: "pointer", fontWeight: 500 }}>
|
||||
<div className="text-block-57">← Назад</div>
|
||||
<div className="w-embed">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div> */}
|
||||
{subcategories.length === 0 && <div style={{ color: "#888", padding: 8 }}>Нет подкатегорий</div>}
|
||||
{subcategories.map((subcat: any, idx: number) => (
|
||||
<div
|
||||
className="div-block-131"
|
||||
key={subcat.quickgroupid || subcat.categoryid || subcat.unitid || subcat.id || idx}
|
||||
onClick={() => handleSubcategoryClick(subcat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="text-block-57">{subcat.name}</div>
|
||||
<div className="w-embed">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
|
||||
</svg>
|
||||
{(() => {
|
||||
// Найти текущий уровень вложенности для selectedCategory
|
||||
let level = 0;
|
||||
let list = categories;
|
||||
while (openedPath[level] && list) {
|
||||
const found = list.find((cat: any) => (cat.quickgroupid || cat.categoryid || cat.id) === openedPath[level]);
|
||||
if (!found) break;
|
||||
if (found === selectedCategory) break;
|
||||
list = found.children || [];
|
||||
level++;
|
||||
}
|
||||
|
||||
// Показываем либо children, либо units
|
||||
if (subcategories.length === 0) {
|
||||
// Если загружаются units для категории без children
|
||||
const categoryId = selectedCategory.categoryid || selectedCategory.quickgroupid || selectedCategory.id;
|
||||
if (activeTab === 'manufacturer' &&
|
||||
(!selectedCategory.children || selectedCategory.children.length === 0) &&
|
||||
!unitsByCategory[categoryId]) {
|
||||
return <div style={{ color: "#888", padding: 8 }}>Загружаем узлы...</div>;
|
||||
}
|
||||
return <div style={{ color: "#888", padding: 8 }}>Нет подкатегорий</div>;
|
||||
}
|
||||
|
||||
return subcategories.map((subcat: any, idx: number) => (
|
||||
<div
|
||||
className="div-block-131"
|
||||
key={subcat.quickgroupid || subcat.categoryid || subcat.unitid || subcat.id || idx}
|
||||
onClick={() => {
|
||||
// Для узлов (units) из режима "От производителя" сразу открываем KnotIn
|
||||
if (activeTab === 'manufacturer' && subcat.unitid && onNodeSelect) {
|
||||
console.log('🔍 VinCategory: Открываем узел напрямую:', subcat);
|
||||
onNodeSelect({
|
||||
...subcat,
|
||||
unitid: subcat.unitid || subcat.quickgroupid || subcat.categoryid || subcat.id
|
||||
});
|
||||
} else {
|
||||
handleCategoryClick(subcat, level + 1);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="text-block-57">{subcat.name}</div>
|
||||
<div className="w-embed">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useLazyQuery, useQuery } from '@apollo/client';
|
||||
import { GET_LAXIMO_FULLTEXT_SEARCH, GET_LAXIMO_CATEGORIES, GET_LAXIMO_UNITS, GET_LAXIMO_QUICK_GROUPS, GET_LAXIMO_QUICK_DETAIL } from '@/lib/graphql/laximo';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface VinLeftbarProps {
|
||||
vehicleInfo?: {
|
||||
@ -19,6 +20,10 @@ interface VinLeftbarProps {
|
||||
onNodeSelect?: (node: any) => void;
|
||||
onActiveTabChange?: (tab: 'uzly' | 'manufacturer') => void;
|
||||
onQuickGroupSelect?: (group: any) => void;
|
||||
activeTab?: 'uzly' | 'manufacturer';
|
||||
openedPath?: string[];
|
||||
setOpenedPath?: (path: string[]) => void;
|
||||
onCloseQuickGroup?: () => void;
|
||||
}
|
||||
|
||||
interface QuickGroup {
|
||||
@ -28,13 +33,12 @@ interface QuickGroup {
|
||||
children?: QuickGroup[];
|
||||
}
|
||||
|
||||
const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, onNodeSelect, onActiveTabChange, onQuickGroupSelect }) => {
|
||||
const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, onNodeSelect, onActiveTabChange, onQuickGroupSelect, activeTab: activeTabProp, openedPath = [], setOpenedPath = () => {}, onCloseQuickGroup }) => {
|
||||
const router = useRouter();
|
||||
const catalogCode = vehicleInfo?.catalog || '';
|
||||
const vehicleId = vehicleInfo?.vehicleid || '';
|
||||
const ssd = vehicleInfo?.ssd || '';
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly');
|
||||
const [executeSearch, { data, loading, error }] = useLazyQuery(GET_LAXIMO_FULLTEXT_SEARCH, { errorPolicy: 'all' });
|
||||
|
||||
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(GET_LAXIMO_CATEGORIES, {
|
||||
@ -58,11 +62,60 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
|
||||
const lastCategoryIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const handleToggle = (idx: number, categoryId: string) => {
|
||||
setOpenIndex(openIndex === idx ? null : idx);
|
||||
if (openIndex !== idx && !unitsByCategory[categoryId]) {
|
||||
lastCategoryIdRef.current = categoryId;
|
||||
getUnits({ variables: { catalogCode, vehicleId, ssd, categoryId } });
|
||||
// --- Синхронизация openedPath с URL ---
|
||||
// Обновляем openedPath и URL
|
||||
const setOpenedPathAndUrl = (newPath: string[]) => {
|
||||
setOpenedPath(newPath);
|
||||
if (onCloseQuickGroup) onCloseQuickGroup();
|
||||
const params = new URLSearchParams(router.query as any);
|
||||
if (newPath.length > 0) {
|
||||
params.set('catpath', newPath.join(','));
|
||||
} else {
|
||||
params.delete('catpath');
|
||||
}
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: { ...router.query, catpath: newPath.join(',') } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Восстанавливаем openedPath из URL
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const catpath = (router.query.catpath as string) || '';
|
||||
if (catpath) {
|
||||
setOpenedPath(catpath.split(',').filter(Boolean));
|
||||
} else {
|
||||
setOpenedPath([]);
|
||||
}
|
||||
}, [router.query.catpath]);
|
||||
|
||||
const handleToggle = (categoryId: string, level: number) => {
|
||||
console.log('🔄 VinLeftbar: handleToggle вызван для categoryId:', categoryId, 'level:', level, 'текущий openedPath:', openedPath);
|
||||
|
||||
if (openedPath[level] === categoryId) {
|
||||
const newPath = openedPath.slice(0, level);
|
||||
console.log('🔄 VinLeftbar: Закрываем категорию, новый path:', newPath);
|
||||
setOpenedPathAndUrl(newPath);
|
||||
} else {
|
||||
const newPath = [...openedPath.slice(0, level), categoryId];
|
||||
console.log('🔄 VinLeftbar: Открываем категорию, новый path:', newPath);
|
||||
setOpenedPathAndUrl(newPath);
|
||||
|
||||
// Загружаем units для категории, если они еще не загружены
|
||||
if (activeTabProp === 'manufacturer' && !unitsByCategory[categoryId]) {
|
||||
console.log('🔄 VinLeftbar: Загружаем units для categoryId:', categoryId);
|
||||
lastCategoryIdRef.current = categoryId;
|
||||
getUnits({
|
||||
variables: {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
ssd,
|
||||
categoryId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -117,26 +170,11 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
});
|
||||
const quickGroups = quickGroupsData?.laximoQuickGroups || [];
|
||||
|
||||
const [expandedQuickGroup, setExpandedQuickGroup] = useState<string | null>(null);
|
||||
const [expandedSubQuickGroup, setExpandedSubQuickGroup] = useState<string | null>(null);
|
||||
|
||||
const handleQuickGroupToggle = (groupId: string) => {
|
||||
setExpandedQuickGroup(prev => (prev === groupId ? null : groupId));
|
||||
setExpandedSubQuickGroup(null);
|
||||
};
|
||||
|
||||
const handleSubQuickGroupToggle = (groupId: string) => {
|
||||
setExpandedSubQuickGroup(prev => (prev === groupId ? null : groupId));
|
||||
};
|
||||
|
||||
const handleQuickGroupClick = (group: any) => {
|
||||
if (group.link) {
|
||||
// Передаем выбранную группу в родительский компонент для отображения справа
|
||||
if (onQuickGroupSelect) {
|
||||
onQuickGroupSelect(group);
|
||||
}
|
||||
const handleQuickGroupToggle = (groupId: string, level: number) => {
|
||||
if (openedPath[level] === groupId) {
|
||||
setOpenedPathAndUrl(openedPath.slice(0, level));
|
||||
} else {
|
||||
handleQuickGroupToggle(group.quickgroupid);
|
||||
setOpenedPathAndUrl([...openedPath.slice(0, level), groupId]);
|
||||
}
|
||||
};
|
||||
|
||||
@ -207,12 +245,6 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
|
||||
const fulltextResults = fulltextData?.laximoFulltextSearch?.details || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (onActiveTabChange) {
|
||||
onActiveTabChange(activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Если нет данных о транспортном средстве, показываем заглушку
|
||||
if (!vehicleInfo) {
|
||||
return (
|
||||
@ -281,18 +313,16 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className={
|
||||
searchQuery
|
||||
? 'button-23 w-button'
|
||||
: activeTab === 'uzly'
|
||||
: activeTabProp === 'uzly'
|
||||
? 'button-3 w-button'
|
||||
: 'button-23 w-button'
|
||||
}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (searchQuery) setSearchQuery('');
|
||||
setActiveTab('uzly');
|
||||
// Очищаем выбранную группу при смене таба
|
||||
if (onQuickGroupSelect) {
|
||||
onQuickGroupSelect(null);
|
||||
}
|
||||
if (onActiveTabChange) onActiveTabChange('uzly');
|
||||
if (onQuickGroupSelect) onQuickGroupSelect(null);
|
||||
if (onCloseQuickGroup) onCloseQuickGroup();
|
||||
}}
|
||||
>
|
||||
Узлы
|
||||
@ -302,25 +332,23 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className={
|
||||
searchQuery
|
||||
? 'button-23 w-button'
|
||||
: activeTab === 'manufacturer'
|
||||
: activeTabProp === 'manufacturer'
|
||||
? 'button-3 w-button'
|
||||
: 'button-23 w-button'
|
||||
}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (searchQuery) setSearchQuery('');
|
||||
setActiveTab('manufacturer');
|
||||
// Очищаем выбранную группу при смене таба
|
||||
if (onQuickGroupSelect) {
|
||||
onQuickGroupSelect(null);
|
||||
}
|
||||
if (onActiveTabChange) onActiveTabChange('manufacturer');
|
||||
if (onQuickGroupSelect) onQuickGroupSelect(null);
|
||||
if (onCloseQuickGroup) onCloseQuickGroup();
|
||||
}}
|
||||
>
|
||||
От производителя
|
||||
</a>
|
||||
</div>
|
||||
{/* Tab content start */}
|
||||
{activeTab === 'uzly' ? (
|
||||
{activeTabProp === 'uzly' ? (
|
||||
// Общие (QuickGroups - бывшие "От производителя")
|
||||
quickGroupsLoading ? (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>Загружаем группы быстрого поиска...</div>
|
||||
@ -330,7 +358,7 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
<>
|
||||
{(quickGroups as QuickGroup[]).map((group: QuickGroup) => {
|
||||
const hasChildren = group.children && group.children.length > 0;
|
||||
const isOpen = expandedQuickGroup === group.quickgroupid;
|
||||
const isOpen = openedPath.includes(group.quickgroupid);
|
||||
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
@ -340,7 +368,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className="dropdown-link-3 w-dropdown-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickGroupClick(group);
|
||||
// Если это конечная группа с link=true, открываем QuickGroup
|
||||
if (group.link && onQuickGroupSelect) {
|
||||
onQuickGroupSelect(group);
|
||||
} else {
|
||||
handleQuickGroupToggle(group.quickgroupid, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
@ -357,7 +390,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
>
|
||||
<div
|
||||
className={`dropdown-toggle-3 w-dropdown-toggle${isOpen ? " w--open active" : ""}`}
|
||||
onClick={() => handleQuickGroupToggle(group.quickgroupid)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickGroupToggle(group.quickgroupid, 0);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="w-icon-dropdown-toggle"></div>
|
||||
@ -366,7 +402,7 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
<nav className={`dropdown-list-4 w-dropdown-list${isOpen ? " w--open" : ""}`}>
|
||||
{group.children?.map((child: QuickGroup) => {
|
||||
const hasSubChildren = child.children && child.children.length > 0;
|
||||
const isChildOpen = expandedSubQuickGroup === child.quickgroupid;
|
||||
const isChildOpen = openedPath.includes(child.quickgroupid);
|
||||
|
||||
if (!hasSubChildren) {
|
||||
return (
|
||||
@ -376,7 +412,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className="dropdown-link-3 w-dropdown-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickGroupClick(child);
|
||||
// Если это конечная группа с link=true, открываем QuickGroup
|
||||
if (child.link && onQuickGroupSelect) {
|
||||
onQuickGroupSelect(child);
|
||||
} else {
|
||||
handleQuickGroupToggle(child.quickgroupid, 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{child.name}
|
||||
@ -393,7 +434,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
>
|
||||
<div
|
||||
className={`dropdown-toggle-card w-dropdown-toggle pl-0${isChildOpen ? " w--open active" : ""}`}
|
||||
onClick={() => handleSubQuickGroupToggle(child.quickgroupid)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickGroupToggle(child.quickgroupid, 2);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="w-icon-dropdown-toggle"></div>
|
||||
@ -407,7 +451,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className="dropdown-link-3 w-dropdown-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickGroupClick(subChild);
|
||||
// Если это конечная группа с link=true, открываем QuickGroup
|
||||
if (subChild.link && onQuickGroupSelect) {
|
||||
onQuickGroupSelect(subChild);
|
||||
} else {
|
||||
handleQuickGroupToggle(subChild.quickgroupid, 3);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subChild.name}
|
||||
@ -434,20 +483,25 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
) : (
|
||||
<>
|
||||
{categories.map((category: any, idx: number) => {
|
||||
const isOpen = openIndex === idx;
|
||||
// ИСПРАВЛЕНИЕ: Используем тот же приоритет ID, что и в VinCategory
|
||||
const categoryId = category.quickgroupid || category.categoryid || category.id;
|
||||
const isOpen = openedPath.includes(categoryId);
|
||||
const subcategories = category.children && category.children.length > 0
|
||||
? category.children
|
||||
: unitsByCategory[category.quickgroupid] || [];
|
||||
: unitsByCategory[categoryId] || [];
|
||||
return (
|
||||
<div
|
||||
key={category.quickgroupid}
|
||||
key={categoryId}
|
||||
data-hover="false"
|
||||
data-delay="0"
|
||||
className={`dropdown-4 w-dropdown${isOpen ? " w--open" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`dropdown-toggle-3 w-dropdown-toggle${isOpen ? " w--open" : ""}`}
|
||||
onClick={() => handleToggle(idx, category.quickgroupid)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggle(categoryId, 0);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="w-icon-dropdown-toggle"></div>
|
||||
@ -462,11 +516,23 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
|
||||
className="dropdown-link-3 w-dropdown-link pl-0"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
// Для вкладки "От производителя" всегда открываем узел, не используем QuickGroup
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect({
|
||||
const nodeToSelect = {
|
||||
...subcat,
|
||||
unitid: subcat.unitid || subcat.quickgroupid || subcat.id
|
||||
};
|
||||
|
||||
// ОТЛАДКА: Логируем передачу узла
|
||||
console.log('🔍 VinLeftbar передает узел:', {
|
||||
unitId: nodeToSelect.unitid,
|
||||
unitName: nodeToSelect.name,
|
||||
hasOriginalSsd: !!subcat.ssd,
|
||||
originalSsd: subcat.ssd ? `${subcat.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
finalSsd: nodeToSelect.ssd ? `${nodeToSelect.ssd.substring(0, 50)}...` : 'отсутствует'
|
||||
});
|
||||
|
||||
onNodeSelect(nodeToSelect);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -72,8 +72,8 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
|
||||
<div className="knot-img">
|
||||
<h1 className="heading-19">{unit.name}</h1>
|
||||
|
||||
{unit.details && unit.details.length > 0 && unit.details.map((detail: any) => (
|
||||
<div className="w-layout-hflex flex-block-115" key={detail.detailid}>
|
||||
{unit.details && unit.details.length > 0 && unit.details.map((detail: any, index: number) => (
|
||||
<div className="w-layout-hflex flex-block-115" key={`${unit.unitid}-${detail.detailid || index}`}>
|
||||
<div className="oemnuber">{detail.oem}</div>
|
||||
<div className="partsname">{detail.name}</div>
|
||||
<a href="#" className="button-3 w-button" onClick={e => { e.preventDefault(); handleDetailClick(detail); }}>Показать цены</a>
|
||||
|
@ -25,6 +25,8 @@ const initialState: CartState = {
|
||||
// Типы действий
|
||||
type CartAction =
|
||||
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'id' | 'selected' | 'favorite'> }
|
||||
| { type: 'ADD_ITEM_SUCCESS'; payload: { items: CartItem[]; summary: any } }
|
||||
| { type: 'ADD_ITEM_ERROR'; payload: string }
|
||||
| { type: 'REMOVE_ITEM'; payload: string }
|
||||
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
|
||||
| { type: 'TOGGLE_SELECT'; payload: string }
|
||||
@ -44,6 +46,16 @@ type CartAction =
|
||||
// Функция для генерации ID
|
||||
const generateId = () => Math.random().toString(36).substr(2, 9)
|
||||
|
||||
// Утилитарная функция для парсинга количества в наличии
|
||||
const parseStock = (stockStr: string | number | undefined): number => {
|
||||
if (typeof stockStr === 'number') return stockStr;
|
||||
if (typeof stockStr === 'string') {
|
||||
const match = stockStr.match(/\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Функция для расчета итогов
|
||||
const calculateSummary = (items: CartItem[], deliveryPrice: number) => {
|
||||
const selectedItems = items.filter(item => item.selected)
|
||||
@ -53,7 +65,8 @@ const calculateSummary = (items: CartItem[], deliveryPrice: number) => {
|
||||
const discount = item.originalPrice ? (item.originalPrice - item.price) * item.quantity : 0
|
||||
return sum + discount
|
||||
}, 0)
|
||||
const finalPrice = totalPrice + deliveryPrice
|
||||
// Доставка включена в стоимость товаров, поэтому добавляем её только если есть товары
|
||||
const finalPrice = totalPrice + (totalPrice > 0 ? 0 : 0) // Доставка всегда включена в цену товаров
|
||||
|
||||
return {
|
||||
totalItems,
|
||||
@ -78,9 +91,12 @@ const cartReducer = (state: CartState, action: CartAction): CartState => {
|
||||
|
||||
if (existingItemIndex >= 0) {
|
||||
// Увеличиваем количество существующего товара
|
||||
const existingItem = state.items[existingItemIndex];
|
||||
const totalQuantity = existingItem.quantity + action.payload.quantity;
|
||||
|
||||
newItems = state.items.map((item, index) =>
|
||||
index === existingItemIndex
|
||||
? { ...item, quantity: item.quantity + action.payload.quantity }
|
||||
? { ...item, quantity: totalQuantity }
|
||||
: item
|
||||
)
|
||||
} else {
|
||||
@ -335,8 +351,31 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [state.items, state.delivery, state.orderComment, isInitialized])
|
||||
|
||||
// Функции для работы с корзиной
|
||||
const addItem = (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
|
||||
const addItem = async (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
|
||||
// Проверяем наличие товара на складе перед добавлением
|
||||
const existingItemIndex = state.items.findIndex(
|
||||
existingItem =>
|
||||
(existingItem.productId && existingItem.productId === item.productId) ||
|
||||
(existingItem.offerKey && existingItem.offerKey === item.offerKey)
|
||||
)
|
||||
|
||||
let totalQuantity = item.quantity;
|
||||
if (existingItemIndex >= 0) {
|
||||
const existingItem = state.items[existingItemIndex];
|
||||
totalQuantity = existingItem.quantity + item.quantity;
|
||||
}
|
||||
|
||||
// Проверяем наличие товара на складе
|
||||
const availableStock = parseStock(item.stock);
|
||||
if (availableStock > 0 && totalQuantity > availableStock) {
|
||||
const errorMessage = `Недостаточно товара в наличии. Доступно: ${availableStock} шт., запрошено: ${totalQuantity} шт.`;
|
||||
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
// Если проверка прошла успешно, добавляем товар
|
||||
dispatch({ type: 'ADD_ITEM', payload: item })
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
@ -344,6 +383,17 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
|
||||
const updateQuantity = (id: string, quantity: number) => {
|
||||
// Найдем товар для проверки наличия
|
||||
const item = state.items.find(item => item.id === id);
|
||||
if (item) {
|
||||
const availableStock = parseStock(item.stock);
|
||||
if (availableStock > 0 && quantity > availableStock) {
|
||||
// Показываем ошибку, но не изменяем количество
|
||||
dispatch({ type: 'SET_ERROR', payload: `Недостаточно товара в наличии. Доступно: ${availableStock} шт.` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
|
||||
}
|
||||
|
||||
@ -388,6 +438,10 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
dispatch({ type: 'SET_ERROR', payload: '' })
|
||||
}
|
||||
|
||||
const contextValue: CartContextType = {
|
||||
state,
|
||||
addItem,
|
||||
@ -401,7 +455,8 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
removeAll,
|
||||
removeSelected,
|
||||
updateDelivery,
|
||||
clearCart
|
||||
clearCart,
|
||||
clearError
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import toast from 'react-hot-toast'
|
||||
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
|
||||
import DeleteCartIcon from '@/components/DeleteCartIcon'
|
||||
|
||||
// Типы
|
||||
export interface FavoriteItem {
|
||||
@ -133,7 +134,9 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
|
||||
|
||||
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
|
||||
onCompleted: () => {
|
||||
toast.success('Товар удален из избранного')
|
||||
toast.success('Товар удален из избранного', {
|
||||
icon: <DeleteCartIcon size={20} color="#ec1c24" />,
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка удаления из избранного:', error)
|
||||
|
@ -231,6 +231,7 @@ export const useCatalogPrices = (): UseCatalogPricesReturn => {
|
||||
price: cheapestOffer.price,
|
||||
currency: cheapestOffer.currency || 'RUB',
|
||||
quantity: 1,
|
||||
stock: cheapestOffer.quantity, // передаем информацию о наличии
|
||||
deliveryTime: cheapestOffer.deliveryDays?.toString() || '0',
|
||||
warehouse: cheapestOffer.warehouse || 'Склад',
|
||||
supplier: cheapestOffer.supplierName || 'Неизвестный поставщик',
|
||||
@ -238,10 +239,14 @@ export const useCatalogPrices = (): UseCatalogPricesReturn => {
|
||||
image: '', // Убираем мокап-фотку, изображения будут загружаться отдельно
|
||||
};
|
||||
|
||||
addItem(itemToAdd);
|
||||
const result = await addItem(itemToAdd);
|
||||
|
||||
// Показываем уведомление
|
||||
toast.success(`Товар "${brand} ${articleNumber}" добавлен в корзину за ${cheapestOffer.price} ₽`);
|
||||
if (result.success) {
|
||||
// Показываем уведомление
|
||||
toast.success(`Товар "${brand} ${articleNumber}" добавлен в корзину за ${cheapestOffer.price} ₽`);
|
||||
} else {
|
||||
toast.error(result.error || 'Ошибка добавления товара в корзину');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления в корзину:', error);
|
||||
|
30
src/hooks/useMetaTags.ts
Normal file
30
src/hooks/useMetaTags.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
interface MetaTagsData {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
}
|
||||
|
||||
export const useMetaTags = (customMeta?: Partial<MetaTagsData>): MetaTagsData => {
|
||||
const router = useRouter();
|
||||
|
||||
const metaData = useMemo(() => {
|
||||
// Получаем базовые meta-теги для текущего пути
|
||||
const baseMeta = getMetaByPath(router.asPath);
|
||||
|
||||
// Объединяем с пользовательскими meta-тегами
|
||||
return {
|
||||
...baseMeta,
|
||||
...customMeta
|
||||
};
|
||||
}, [router.asPath, customMeta]);
|
||||
|
||||
return metaData;
|
||||
};
|
||||
|
||||
export default useMetaTags;
|
61
src/lib/cookie-utils.ts
Normal file
61
src/lib/cookie-utils.ts
Normal file
@ -0,0 +1,61 @@
|
||||
interface CookiePreferences {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
}
|
||||
|
||||
export const getCookieConsent = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('cookieConsent');
|
||||
};
|
||||
|
||||
export const getCookiePreferences = (): CookiePreferences | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const preferences = localStorage.getItem('cookiePreferences');
|
||||
return preferences ? JSON.parse(preferences) : null;
|
||||
};
|
||||
|
||||
export const hasConsentForAnalytics = (): boolean => {
|
||||
const preferences = getCookiePreferences();
|
||||
return preferences?.analytics || false;
|
||||
};
|
||||
|
||||
export const hasConsentForMarketing = (): boolean => {
|
||||
const preferences = getCookiePreferences();
|
||||
return preferences?.marketing || false;
|
||||
};
|
||||
|
||||
export const hasConsentForFunctional = (): boolean => {
|
||||
const preferences = getCookiePreferences();
|
||||
return preferences?.functional || false;
|
||||
};
|
||||
|
||||
export const isConsentGiven = (): boolean => {
|
||||
const consent = getCookieConsent();
|
||||
return consent !== null && consent !== 'declined';
|
||||
};
|
||||
|
||||
export const resetCookieConsent = (): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('cookieConsent');
|
||||
localStorage.removeItem('cookiePreferences');
|
||||
};
|
||||
|
||||
// Функция для интеграции с аналитикой (например, Google Analytics)
|
||||
export const initializeAnalytics = (): void => {
|
||||
if (!hasConsentForAnalytics()) return;
|
||||
|
||||
// Здесь можно добавить инициализацию Google Analytics или других сервисов
|
||||
console.log('Analytics initialized with user consent');
|
||||
};
|
||||
|
||||
// Функция для интеграции с маркетинговыми инструментами
|
||||
export const initializeMarketing = (): void => {
|
||||
if (!hasConsentForMarketing()) return;
|
||||
|
||||
// Здесь можно добавить инициализацию маркетинговых пикселей
|
||||
console.log('Marketing tools initialized with user consent');
|
||||
};
|
||||
|
||||
export type { CookiePreferences };
|
397
src/lib/meta-config.ts
Normal file
397
src/lib/meta-config.ts
Normal file
@ -0,0 +1,397 @@
|
||||
interface MetaConfig {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
}
|
||||
|
||||
export const metaConfig: Record<string, MetaConfig> = {
|
||||
// Главная страница
|
||||
'/': {
|
||||
title: 'Protek - Автозапчасти и аксессуары для всех марок автомобилей',
|
||||
description: 'Protek - широкий ассортимент автозапчастей и аксессуаров для всех марок автомобилей. Быстрая доставка по России, гарантия качества, низкие цены.',
|
||||
keywords: 'автозапчасти, запчасти для автомобилей, автоаксессуары, доставка запчастей, protek, протек',
|
||||
ogTitle: 'Protek - Автозапчасти и аксессуары',
|
||||
ogDescription: 'Широкий ассортимент автозапчастей и аксессуаров для всех марок автомобилей. Быстрая доставка, гарантия качества.'
|
||||
},
|
||||
|
||||
// Каталог
|
||||
'/catalog': {
|
||||
title: 'Каталог автозапчастей - Protek',
|
||||
description: 'Полный каталог автозапчастей для всех марок автомобилей. Более 1 миллиона наименований запчастей в наличии и под заказ.',
|
||||
keywords: 'каталог запчастей, автозапчасти каталог, запчасти для авто, поиск запчастей',
|
||||
ogTitle: 'Каталог автозапчастей - Protek',
|
||||
ogDescription: 'Полный каталог автозапчастей для всех марок автомобилей. Более 1 миллиона наименований.'
|
||||
},
|
||||
|
||||
// Марки автомобилей
|
||||
'/brands': {
|
||||
title: 'Все марки автомобилей - Каталог запчастей Protek',
|
||||
description: 'Полный каталог автомобильных брендов для поиска запчастей. Выберите марку вашего автомобиля и найдите нужные запчасти.',
|
||||
keywords: 'марки автомобилей, бренды авто, запчасти по маркам, автомобильные марки',
|
||||
ogTitle: 'Все марки автомобилей - Protek',
|
||||
ogDescription: 'Полный каталог автомобильных брендов для поиска запчастей.'
|
||||
},
|
||||
|
||||
// Поиск по VIN
|
||||
'/vin': {
|
||||
title: 'Поиск запчастей по VIN коду - Protek',
|
||||
description: 'Быстрый и точный поиск автозапчастей по VIN коду автомобиля. Определите совместимые запчасти для вашего авто.',
|
||||
keywords: 'поиск по VIN, VIN код, запчасти по VIN, определение запчастей, совместимость',
|
||||
ogTitle: 'Поиск запчастей по VIN коду - Protek',
|
||||
ogDescription: 'Быстрый и точный поиск автозапчастей по VIN коду автомобиля.'
|
||||
},
|
||||
|
||||
// Контакты
|
||||
'/contacts': {
|
||||
title: 'Контакты - Protek',
|
||||
description: 'Контактная информация компании Protek. Адреса магазинов, телефоны, режим работы. Свяжитесь с нами для консультации.',
|
||||
keywords: 'контакты protek, адрес, телефон, режим работы, магазины запчастей',
|
||||
ogTitle: 'Контакты - Protek',
|
||||
ogDescription: 'Контактная информация компании Protek. Адреса магазинов, телефоны, режим работы.'
|
||||
},
|
||||
|
||||
// О компании
|
||||
'/about': {
|
||||
title: 'О компании Protek - Автозапчасти и аксессуары',
|
||||
description: 'Компания Protek - надежный поставщик автозапчастей с многолетним опытом. Узнайте больше о нашей истории и преимуществах.',
|
||||
keywords: 'о компании protek, история компании, преимущества, автозапчасти',
|
||||
ogTitle: 'О компании Protek',
|
||||
ogDescription: 'Компания Protek - надежный поставщик автозапчастей с многолетним опытом.'
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Оптовые продажи
|
||||
'/wholesale': {
|
||||
title: 'Оптовые продажи автозапчастей - Protek',
|
||||
description: 'Оптовые продажи автозапчастей для автосервисов и дилеров. Специальные цены, гибкие условия сотрудничества.',
|
||||
keywords: 'оптовые продажи, запчасти оптом, для автосервисов, дилерам, оптовые цены',
|
||||
ogTitle: 'Оптовые продажи автозапчастей - Protek',
|
||||
ogDescription: 'Оптовые продажи автозапчастей для автосервисов и дилеров. Специальные цены.'
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Корзина
|
||||
'/cart': {
|
||||
title: 'Корзина - Protek',
|
||||
description: 'Корзина покупок. Оформите заказ на выбранные автозапчасти с быстрой доставкой.',
|
||||
keywords: 'корзина покупок, оформление заказа, заказать запчасти',
|
||||
ogTitle: 'Корзина - Protek',
|
||||
ogDescription: 'Корзина покупок. Оформите заказ на выбранные автозапчасти.'
|
||||
},
|
||||
|
||||
// Новости
|
||||
'/news': {
|
||||
title: 'Новости - Protek',
|
||||
description: 'Актуальные новости компании Protek, события автомобильной индустрии и мира автозапчастей.',
|
||||
keywords: 'новости protek, автомобильные новости, события автоиндустрии',
|
||||
ogTitle: 'Новости - Protek',
|
||||
ogDescription: 'Актуальные новости компании Protek и автомобильной индустрии.'
|
||||
},
|
||||
|
||||
// Карточка товара
|
||||
'/card': {
|
||||
title: 'Карточка товара - Protek',
|
||||
description: 'Подробная информация о товаре: характеристики, цены, наличие, отзывы.',
|
||||
keywords: 'карточка товара, характеристики запчасти, цена, наличие',
|
||||
ogTitle: 'Карточка товара - Protek',
|
||||
ogDescription: 'Подробная информация о товаре: характеристики, цены, наличие.'
|
||||
},
|
||||
|
||||
// Поиск автомобилей по артикулу
|
||||
'/vehicles-by-part': {
|
||||
title: 'Автомобили по артикулу - Protek',
|
||||
description: 'Поиск автомобилей, в которых используется деталь с указанным артикулом.',
|
||||
keywords: 'автомобили по артикулу, применимость детали, где используется',
|
||||
ogTitle: 'Автомобили по артикулу - Protek',
|
||||
ogDescription: 'Поиск автомобилей, в которых используется деталь с указанным артикулом.'
|
||||
},
|
||||
|
||||
// Страницы оплаты
|
||||
'/payment/success': {
|
||||
title: 'Оплата прошла успешно - Protek',
|
||||
description: 'Ваш платеж успешно обработан. Спасибо за покупку! Мы приступим к обработке заказа.',
|
||||
keywords: 'оплата успешна, платеж прошел, заказ оплачен',
|
||||
ogTitle: 'Оплата прошла успешно - Protek',
|
||||
ogDescription: 'Ваш платеж успешно обработан. Спасибо за покупку!'
|
||||
},
|
||||
|
||||
'/payment/cancelled': {
|
||||
title: 'Оплата отменена - Protek',
|
||||
description: 'Платеж был отменен. Вы можете попробовать оплатить заказ повторно.',
|
||||
keywords: 'оплата отменена, платеж отклонен, повторная оплата',
|
||||
ogTitle: 'Оплата отменена - Protek',
|
||||
ogDescription: 'Платеж был отменен. Вы можете попробовать оплатить заказ повторно.'
|
||||
},
|
||||
|
||||
'/payment/failed': {
|
||||
title: 'Ошибка оплаты - Protek',
|
||||
description: 'Произошла ошибка при обработке платежа. Попробуйте еще раз или выберите другой способ оплаты.',
|
||||
keywords: 'ошибка оплаты, платеж не прошел, проблема с оплатой',
|
||||
ogTitle: 'Ошибка оплаты - Protek',
|
||||
ogDescription: 'Произошла ошибка при обработке платежа. Попробуйте еще раз.'
|
||||
},
|
||||
|
||||
'/payment/invoice': {
|
||||
title: 'Счёт на оплату - Protek',
|
||||
description: 'Счёт на оплату заказа. Вы можете оплатить удобным для вас способом.',
|
||||
keywords: 'счет на оплату, инвойс, оплата заказа',
|
||||
ogTitle: 'Счёт на оплату - Protek',
|
||||
ogDescription: 'Счёт на оплату заказа. Вы можете оплатить удобным для вас способом.'
|
||||
},
|
||||
|
||||
// Дополнительные страницы профиля
|
||||
'/profile-req': {
|
||||
title: 'Реквизиты - Личный кабинет Protek',
|
||||
description: 'Управление реквизитами организации в личном кабинете.',
|
||||
keywords: 'реквизиты организации, личный кабинет, данные компании',
|
||||
ogTitle: 'Реквизиты - Protek',
|
||||
ogDescription: 'Управление реквизитами организации в личном кабинете.'
|
||||
},
|
||||
|
||||
'/profile-acts': {
|
||||
title: 'Акты сверки - Личный кабинет Protek',
|
||||
description: 'Акты сверки взаиморасчетов в личном кабинете.',
|
||||
keywords: 'акты сверки, взаиморасчеты, личный кабинет',
|
||||
ogTitle: 'Акты сверки - Protek',
|
||||
ogDescription: 'Акты сверки взаиморасчетов в личном кабинете.'
|
||||
},
|
||||
|
||||
'/profile-balance': {
|
||||
title: 'Баланс - Личный кабинет Protek',
|
||||
description: 'Информация о балансе и финансовых операциях в личном кабинете.',
|
||||
keywords: 'баланс счета, финансы, личный кабинет',
|
||||
ogTitle: 'Баланс - Protek',
|
||||
ogDescription: 'Информация о балансе и финансовых операциях.'
|
||||
},
|
||||
|
||||
// Процесс заказа
|
||||
'/order-confirmation': {
|
||||
title: 'Подтверждение заказа - Protek',
|
||||
description: 'Подтверждение оформленного заказа. Проверьте данные перед финальным подтверждением.',
|
||||
keywords: 'подтверждение заказа, проверка заказа, финальный шаг',
|
||||
ogTitle: 'Подтверждение заказа - Protek',
|
||||
ogDescription: 'Подтверждение оформленного заказа. Проверьте данные.'
|
||||
},
|
||||
|
||||
'/cart-step-2': {
|
||||
title: 'Оформление заказа - Шаг 2 - Protek',
|
||||
description: 'Второй шаг оформления заказа. Выберите способ доставки и оплаты.',
|
||||
keywords: 'оформление заказа шаг 2, доставка, способ оплаты',
|
||||
ogTitle: 'Оформление заказа - Шаг 2',
|
||||
ogDescription: 'Второй шаг оформления заказа. Выберите способ доставки и оплаты.'
|
||||
},
|
||||
|
||||
'/payments-method': {
|
||||
title: 'Способы оплаты - Protek',
|
||||
description: 'Выберите удобный способ оплаты: наличными, картой, банковским переводом.',
|
||||
keywords: 'способы оплаты, оплата картой, наличные, банковский перевод',
|
||||
ogTitle: 'Способы оплаты - Protek',
|
||||
ogDescription: 'Выберите удобный способ оплаты: наличными, картой, банковским переводом.'
|
||||
},
|
||||
|
||||
'/checkout': {
|
||||
title: 'Оформление заказа - Protek',
|
||||
description: 'Оформление заказа автозапчастей. Быстро и безопасно.',
|
||||
keywords: 'оформление заказа, checkout, заказать запчасти',
|
||||
ogTitle: 'Оформление заказа - Protek',
|
||||
ogDescription: 'Оформление заказа автозапчастей. Быстро и безопасно.'
|
||||
},
|
||||
|
||||
// Детальные страницы
|
||||
'/detail_category': {
|
||||
title: 'Категория товаров - Protek',
|
||||
description: 'Просмотр товаров в выбранной категории автозапчастей.',
|
||||
keywords: 'категория товаров, группа запчастей, каталог',
|
||||
ogTitle: 'Категория товаров - Protek',
|
||||
ogDescription: 'Просмотр товаров в выбранной категории автозапчастей.'
|
||||
},
|
||||
|
||||
'/detail_product': {
|
||||
title: 'Детальная информация о товаре - Protek',
|
||||
description: 'Подробная информация о товаре: технические характеристики, совместимость, цены.',
|
||||
keywords: 'детальная информация, технические характеристики, совместимость',
|
||||
ogTitle: 'Детальная информация о товаре - Protek',
|
||||
ogDescription: 'Подробная информация о товаре: технические характеристики, совместимость.'
|
||||
},
|
||||
|
||||
'/detail_sku': {
|
||||
title: 'Информация о SKU - Protek',
|
||||
description: 'Детальная информация о конкретном артикуле товара.',
|
||||
keywords: 'информация SKU, артикул товара, детали товара',
|
||||
ogTitle: 'Информация о SKU - Protek',
|
||||
ogDescription: 'Детальная информация о конкретном артикуле товара.'
|
||||
},
|
||||
|
||||
// Избранное
|
||||
'/favorite': {
|
||||
title: 'Избранные товары - Protek',
|
||||
description: 'Ваши избранные автозапчасти. Сохраните интересующие товары для быстрого доступа.',
|
||||
keywords: 'избранные товары, сохраненные запчасти, избранное',
|
||||
ogTitle: 'Избранные товары - Protek',
|
||||
ogDescription: 'Ваши избранные автозапчасти. Сохраните интересующие товары.'
|
||||
},
|
||||
|
||||
// Страница благодарности
|
||||
'/thankyoupage': {
|
||||
title: 'Спасибо за заказ - Protek',
|
||||
description: 'Ваш заказ успешно оформлен. Мы свяжемся с вами в ближайшее время для подтверждения.',
|
||||
keywords: 'заказ оформлен, спасибо за заказ, подтверждение заказа',
|
||||
ogTitle: 'Спасибо за заказ - Protek',
|
||||
ogDescription: 'Ваш заказ успешно оформлен. Мы свяжемся с вами в ближайшее время.'
|
||||
},
|
||||
|
||||
// Новости - открытая статья
|
||||
'/news-open': {
|
||||
title: 'Новости - Protek',
|
||||
description: 'Читайте актуальные новости и статьи от компании Protek о мире автозапчастей.',
|
||||
keywords: 'новости protek, статьи, автозапчасти новости',
|
||||
ogTitle: 'Новости - Protek',
|
||||
ogDescription: 'Читайте актуальные новости и статьи от компании Protek.'
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Поиск
|
||||
'/search': {
|
||||
title: 'Поиск запчастей - Protek',
|
||||
description: 'Универсальный поиск автозапчастей по артикулу, VIN коду или модели автомобиля.',
|
||||
keywords: 'поиск запчастей, поиск по артикулу, поиск по VIN, универсальный поиск',
|
||||
ogTitle: 'Поиск запчастей - Protek',
|
||||
ogDescription: 'Универсальный поиск автозапчастей по артикулу, VIN коду или модели автомобиля.'
|
||||
},
|
||||
|
||||
// Поиск по артикулу
|
||||
'/article-search': {
|
||||
title: 'Поиск деталей по артикулу - Protek',
|
||||
description: 'Найдите автозапчасти по артикулу или номеру детали. Быстрый и точный поиск в каталоге.',
|
||||
keywords: 'поиск по артикулу, номер детали, поиск запчастей по номеру',
|
||||
ogTitle: 'Поиск деталей по артикулу - Protek',
|
||||
ogDescription: 'Найдите автозапчасти по артикулу или номеру детали.'
|
||||
},
|
||||
|
||||
// Профиль - заказы
|
||||
'/profile-orders': {
|
||||
title: 'Мои заказы - Личный кабинет Protek',
|
||||
description: 'Управляйте своими заказами в личном кабинете. Отслеживайте статус и историю заказов.',
|
||||
keywords: 'мои заказы, личный кабинет, история заказов, статус заказа',
|
||||
ogTitle: 'Мои заказы - Protek',
|
||||
ogDescription: 'Управляйте своими заказами в личном кабинете.'
|
||||
},
|
||||
|
||||
// Профиль - настройки
|
||||
'/profile-set': {
|
||||
title: 'Настройки профиля - Личный кабинет Protek',
|
||||
description: 'Настройки личного кабинета. Управляйте персональными данными и настройками аккаунта.',
|
||||
keywords: 'настройки профиля, личные данные, настройки аккаунта',
|
||||
ogTitle: 'Настройки профиля - Protek',
|
||||
ogDescription: 'Настройки личного кабинета и персональных данных.'
|
||||
},
|
||||
|
||||
// Профиль - адреса
|
||||
'/profile-addresses': {
|
||||
title: 'Мои адреса - Личный кабинет Protek',
|
||||
description: 'Управляйте адресами доставки в личном кабинете. Добавляйте и редактируйте адреса.',
|
||||
keywords: 'адреса доставки, мои адреса, личный кабинет',
|
||||
ogTitle: 'Мои адреса - Protek',
|
||||
ogDescription: 'Управляйте адресами доставки в личном кабинете.'
|
||||
},
|
||||
|
||||
// Профиль - гараж
|
||||
'/profile-gar': {
|
||||
title: 'Мой гараж - Личный кабинет Protek',
|
||||
description: 'Мой гараж - сохраняйте информацию о ваших автомобилях для быстрого подбора запчастей.',
|
||||
keywords: 'мой гараж, мои автомобили, сохраненные авто',
|
||||
ogTitle: 'Мой гараж - Protek',
|
||||
ogDescription: 'Сохраняйте информацию о ваших автомобилях для быстрого подбора запчастей.'
|
||||
},
|
||||
|
||||
// Профиль - история
|
||||
'/profile-history': {
|
||||
title: 'История просмотров - Личный кабинет Protek',
|
||||
description: 'История просмотренных товаров и запчастей. Быстро найдите ранее просмотренные товары.',
|
||||
keywords: 'история просмотров, просмотренные товары, личный кабинет',
|
||||
ogTitle: 'История просмотров - Protek',
|
||||
ogDescription: 'История просмотренных товаров и запчастей.'
|
||||
},
|
||||
|
||||
// VIN поиск (шаг 2)
|
||||
'/vin-step-2': {
|
||||
title: 'Поиск запчастей по VIN - Шаг 2 - Protek',
|
||||
description: 'Второй шаг поиска запчастей по VIN коду. Выберите нужные детали для вашего автомобиля.',
|
||||
keywords: 'VIN поиск шаг 2, выбор деталей, поиск по VIN',
|
||||
ogTitle: 'Поиск запчастей по VIN - Шаг 2',
|
||||
ogDescription: 'Второй шаг поиска запчастей по VIN коду.'
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
|
||||
// Функция для получения meta-тегов по пути
|
||||
export const getMetaByPath = (path: string): MetaConfig => {
|
||||
// Нормализуем путь (убираем query параметры)
|
||||
const normalizedPath = path.split('?')[0];
|
||||
|
||||
// Проверяем точное совпадение
|
||||
if (metaConfig[normalizedPath]) {
|
||||
return metaConfig[normalizedPath];
|
||||
}
|
||||
|
||||
// Проверяем динамические пути
|
||||
if (normalizedPath.startsWith('/vehicle-search/')) {
|
||||
return {
|
||||
title: 'Поиск запчастей по автомобилю - Protek',
|
||||
description: 'Найдите подходящие запчасти для вашего автомобиля. Точный подбор по марке, модели и году выпуска.',
|
||||
keywords: 'поиск запчастей, подбор по автомобилю, запчасти для авто'
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/search-result')) {
|
||||
return {
|
||||
title: 'Результаты поиска - Protek',
|
||||
description: 'Результаты поиска автозапчастей. Найдите нужные запчасти среди широкого ассортимента.',
|
||||
keywords: 'результаты поиска, поиск запчастей, найти запчасти'
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/payment/')) {
|
||||
return {
|
||||
title: 'Оплата заказа - Protek',
|
||||
description: 'Оплата заказа автозапчастей. Безопасные способы оплаты онлайн.',
|
||||
keywords: 'оплата заказа, онлайн оплата, безопасная оплата'
|
||||
};
|
||||
}
|
||||
|
||||
// Возвращаем дефолтные meta-теги
|
||||
return metaConfig['/'];
|
||||
};
|
||||
|
||||
// Функция для создания динамических meta-тегов для товаров
|
||||
export const createProductMeta = (product: {
|
||||
name: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
price?: number;
|
||||
}): MetaConfig => {
|
||||
return {
|
||||
title: `${product.brand} ${product.articleNumber} - ${product.name} - Protek`,
|
||||
description: `Купить ${product.name} ${product.brand} артикул ${product.articleNumber}${product.price ? ` по цене ${product.price} руб.` : ''}. Гарантия качества, быстрая доставка.`,
|
||||
keywords: `${product.name}, ${product.brand}, ${product.articleNumber}, запчасти, автозапчасти`,
|
||||
ogTitle: `${product.brand} ${product.articleNumber} - ${product.name}`,
|
||||
ogDescription: `Купить ${product.name} ${product.brand} артикул ${product.articleNumber}. Гарантия качества, быстрая доставка.`
|
||||
};
|
||||
};
|
||||
|
||||
// Функция для создания meta-тегов для категорий
|
||||
export const createCategoryMeta = (categoryName: string, count?: number): MetaConfig => {
|
||||
return {
|
||||
title: `${categoryName} - Каталог запчастей Protek`,
|
||||
description: `Купить ${categoryName.toLowerCase()} для автомобилей${count ? `. В наличии ${count} товаров` : ''}. Широкий выбор, низкие цены, быстрая доставка.`,
|
||||
keywords: `${categoryName.toLowerCase()}, запчасти, автозапчасти, каталог`,
|
||||
ogTitle: `${categoryName} - Protek`,
|
||||
ogDescription: `Купить ${categoryName.toLowerCase()} для автомобилей. Широкий выбор, низкие цены.`
|
||||
};
|
||||
};
|
257
src/lib/schema.ts
Normal file
257
src/lib/schema.ts
Normal file
@ -0,0 +1,257 @@
|
||||
// Утилиты для генерации микроразметки schema.org
|
||||
export interface SchemaOrgProduct {
|
||||
name: string;
|
||||
description?: string;
|
||||
brand: string;
|
||||
sku: string;
|
||||
image?: string;
|
||||
category?: string;
|
||||
offers: SchemaOrgOffer[];
|
||||
}
|
||||
|
||||
export interface SchemaOrgOffer {
|
||||
price: number;
|
||||
currency: string;
|
||||
availability: string;
|
||||
seller: string;
|
||||
deliveryTime?: string;
|
||||
warehouse?: string;
|
||||
}
|
||||
|
||||
export interface SchemaOrgBreadcrumb {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SchemaOrgOrganization {
|
||||
name: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
logo?: string;
|
||||
contactPoint?: {
|
||||
telephone: string;
|
||||
email?: string;
|
||||
contactType: string;
|
||||
};
|
||||
address?: {
|
||||
streetAddress: string;
|
||||
addressLocality: string;
|
||||
addressRegion: string;
|
||||
postalCode: string;
|
||||
addressCountry: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaOrgLocalBusiness extends SchemaOrgOrganization {
|
||||
openingHours?: string[];
|
||||
geo?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Генератор микроразметки для товара
|
||||
export const generateProductSchema = (product: SchemaOrgProduct): object => {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
description: product.description || `${product.brand} ${product.sku} - ${product.name}`,
|
||||
brand: {
|
||||
"@type": "Brand",
|
||||
name: product.brand
|
||||
},
|
||||
sku: product.sku,
|
||||
mpn: product.sku,
|
||||
image: product.image,
|
||||
category: product.category || "Автозапчасти",
|
||||
offers: product.offers.map(offer => ({
|
||||
"@type": "Offer",
|
||||
price: offer.price,
|
||||
priceCurrency: offer.currency,
|
||||
availability: offer.availability,
|
||||
seller: {
|
||||
"@type": "Organization",
|
||||
name: offer.seller
|
||||
},
|
||||
deliveryLeadTime: offer.deliveryTime,
|
||||
availableAtOrFrom: {
|
||||
"@type": "Place",
|
||||
name: offer.warehouse || "Склад"
|
||||
}
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
// Генератор микроразметки для организации
|
||||
export const generateOrganizationSchema = (org: SchemaOrgOrganization): object => {
|
||||
const schema: any = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: org.name,
|
||||
url: org.url,
|
||||
description: org.description
|
||||
};
|
||||
|
||||
if (org.logo) {
|
||||
schema.logo = org.logo;
|
||||
}
|
||||
|
||||
if (org.contactPoint) {
|
||||
schema.contactPoint = {
|
||||
"@type": "ContactPoint",
|
||||
telephone: org.contactPoint.telephone,
|
||||
email: org.contactPoint.email,
|
||||
contactType: org.contactPoint.contactType
|
||||
};
|
||||
}
|
||||
|
||||
if (org.address) {
|
||||
schema.address = {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: org.address.streetAddress,
|
||||
addressLocality: org.address.addressLocality,
|
||||
addressRegion: org.address.addressRegion,
|
||||
postalCode: org.address.postalCode,
|
||||
addressCountry: org.address.addressCountry
|
||||
};
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
// Генератор микроразметки для местного бизнеса
|
||||
export const generateLocalBusinessSchema = (business: SchemaOrgLocalBusiness): object => {
|
||||
const schema: any = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
name: business.name,
|
||||
url: business.url,
|
||||
description: business.description
|
||||
};
|
||||
|
||||
if (business.contactPoint) {
|
||||
schema.contactPoint = {
|
||||
"@type": "ContactPoint",
|
||||
telephone: business.contactPoint.telephone,
|
||||
email: business.contactPoint.email,
|
||||
contactType: business.contactPoint.contactType
|
||||
};
|
||||
}
|
||||
|
||||
if (business.address) {
|
||||
schema.address = {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: business.address.streetAddress,
|
||||
addressLocality: business.address.addressLocality,
|
||||
addressRegion: business.address.addressRegion,
|
||||
postalCode: business.address.postalCode,
|
||||
addressCountry: business.address.addressCountry
|
||||
};
|
||||
}
|
||||
|
||||
if (business.openingHours) {
|
||||
schema.openingHours = business.openingHours;
|
||||
}
|
||||
|
||||
if (business.geo) {
|
||||
schema.geo = {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: business.geo.latitude,
|
||||
longitude: business.geo.longitude
|
||||
};
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
// Генератор микроразметки для хлебных крошек
|
||||
export const generateBreadcrumbSchema = (breadcrumbs: SchemaOrgBreadcrumb[]): object => {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: breadcrumbs.map((breadcrumb, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: breadcrumb.name,
|
||||
item: breadcrumb.url
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
// Генератор микроразметки для сайта с поиском
|
||||
export const generateWebSiteSchema = (name: string, url: string, searchUrl?: string): object => {
|
||||
const schema: any = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: name,
|
||||
url: url
|
||||
};
|
||||
|
||||
if (searchUrl) {
|
||||
schema.potentialAction = {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
"@type": "EntryPoint",
|
||||
urlTemplate: `${searchUrl}?q={search_term_string}`
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
};
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
// Утилита для конвертации доступности товара в schema.org формат
|
||||
export const convertAvailability = (stock: string | number): string => {
|
||||
const stockNum = typeof stock === 'string' ? parseInt(stock) || 0 : stock;
|
||||
|
||||
if (stockNum > 0) {
|
||||
return "https://schema.org/InStock";
|
||||
} else {
|
||||
return "https://schema.org/OutOfStock";
|
||||
}
|
||||
};
|
||||
|
||||
// Утилита для генерации JSON-LD скрипта
|
||||
export const generateJsonLdScript = (schema: object): string => {
|
||||
return JSON.stringify(schema, null, 2);
|
||||
};
|
||||
|
||||
// Интерфейс для компонента JSON-LD (компонент будет в отдельном файле)
|
||||
export interface JsonLdScriptProps {
|
||||
schema: object;
|
||||
}
|
||||
|
||||
// Данные организации Protek
|
||||
export const PROTEK_ORGANIZATION: SchemaOrgOrganization = {
|
||||
name: "Protek",
|
||||
description: "Protek - широкий ассортимент автозапчастей и аксессуаров для всех марок автомобилей. Быстрая доставка по России, гарантия качества, низкие цены.",
|
||||
url: "https://protek.ru",
|
||||
logo: "https://protek.ru/images/logo.svg",
|
||||
contactPoint: {
|
||||
telephone: "+7-800-555-0123",
|
||||
email: "info@protek.ru",
|
||||
contactType: "customer service"
|
||||
},
|
||||
address: {
|
||||
streetAddress: "ул. Примерная, 123",
|
||||
addressLocality: "Москва",
|
||||
addressRegion: "Москва",
|
||||
postalCode: "123456",
|
||||
addressCountry: "RU"
|
||||
}
|
||||
};
|
||||
|
||||
// Данные для LocalBusiness
|
||||
export const PROTEK_LOCAL_BUSINESS: SchemaOrgLocalBusiness = {
|
||||
...PROTEK_ORGANIZATION,
|
||||
openingHours: [
|
||||
"Mo-Fr 09:00-18:00",
|
||||
"Sa 10:00-16:00"
|
||||
],
|
||||
geo: {
|
||||
latitude: 55.7558,
|
||||
longitude: 37.6176
|
||||
}
|
||||
};
|
@ -14,6 +14,7 @@ import { CartProvider } from '@/contexts/CartContext';
|
||||
import { FavoritesProvider } from '@/contexts/FavoritesContext';
|
||||
import Layout from "@/components/Layout";
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
|
||||
@ -60,8 +61,12 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#22c55e', // Зеленый фон для успешных уведомлений
|
||||
color: '#fff', // Белый текст
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#4ade80',
|
||||
primary: '#22c55e',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
@ -79,6 +84,7 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<CookieConsent />
|
||||
</CartProvider>
|
||||
</FavoritesProvider>
|
||||
</ApolloProvider>
|
||||
|
@ -2,8 +2,23 @@ import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<Html lang="ru">
|
||||
<Head>
|
||||
{/* Базовые meta-теги */}
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="theme-color" content="#dc2626" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Protek" />
|
||||
|
||||
{/* Preconnect для производительности */}
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
|
||||
{/* Favicon */}
|
||||
<link href="/images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
|
@ -8,18 +8,28 @@ import AboutIntro from "@/components/about/AboutIntro";
|
||||
import AboutOffers from "@/components/about/AboutOffers";
|
||||
import AboutProtekInfo from "@/components/about/AboutProtekInfo";
|
||||
import AboutHelp from "@/components/about/AboutHelp";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateOrganizationSchema, generateBreadcrumbSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
||||
|
||||
export default function About() {
|
||||
const metaData = getMetaByPath('/about');
|
||||
|
||||
// Генерируем микроразметку Organization для страницы "О компании"
|
||||
const organizationSchema = generateOrganizationSchema(PROTEK_ORGANIZATION);
|
||||
|
||||
// Генерируем микроразметку BreadcrumbList
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: "Главная", url: "https://protek.ru/" },
|
||||
{ name: "О компании", url: "https://protek.ru/about" }
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>About</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<JsonLdScript schema={organizationSchema} />
|
||||
<JsonLdScript schema={breadcrumbSchema} />
|
||||
<CatalogInfoHeader
|
||||
title="О компании"
|
||||
breadcrumbs={[
|
||||
|
@ -7,6 +7,8 @@ import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import { DOC_FIND_OEM } from "@/lib/graphql";
|
||||
import { LaximoDocFindOEMResult } from "@/types/laximo";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
const InfoArticleSearch = () => (
|
||||
<section className="section-info">
|
||||
@ -57,14 +59,11 @@ const ArticleSearchPage = () => {
|
||||
const result: LaximoDocFindOEMResult | null = data?.laximoDocFindOEM || null;
|
||||
const hasResults = result && result.details && result.details.length > 0;
|
||||
|
||||
const metaData = getMetaByPath('/article-search');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск деталей по артикулу {searchQuery} - Protek</title>
|
||||
<meta name="description" content={`Результаты поиска деталей по артикулу ${searchQuery}`} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoArticleSearch />
|
||||
<div className="page-wrapper bg-[#F5F8FB] min-h-screen">
|
||||
<div className="flex flex-col px-32 pt-10 pb-16 max-md:px-5">
|
||||
|
@ -8,6 +8,8 @@ import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import { GET_LAXIMO_BRANDS } from "@/lib/graphql";
|
||||
import { LaximoBrand } from "@/types/laximo";
|
||||
import BrandWizardSearchSection from "@/components/BrandWizardSearchSection";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
const InfoBrands = () => (
|
||||
<section className="section-info">
|
||||
@ -85,14 +87,11 @@ const BrandsPage = () => {
|
||||
setSelectedLetter(selectedLetter === letter ? '' : letter);
|
||||
};
|
||||
|
||||
const metaData = getMetaByPath('/brands');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Все марки автомобилей - Protek</title>
|
||||
<meta name="description" content="Полный каталог автомобильных брендов для поиска запчастей" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoBrands />
|
||||
<BrandWizardSearchSection />
|
||||
<Footer />
|
||||
|
@ -1,4 +1,7 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath, createProductMeta } from "../lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateProductSchema, convertAvailability, type SchemaOrgProduct } from "@/lib/schema";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useQuery, useLazyQuery } from "@apollo/client";
|
||||
@ -190,14 +193,45 @@ export default function CardPage() {
|
||||
setVisibleOffersCount(INITIAL_OFFERS_COUNT); // Сбрасываем к начальному количеству
|
||||
};
|
||||
|
||||
// Создаем meta-теги
|
||||
const metaConfig = result ? createProductMeta({
|
||||
name: result.name,
|
||||
brand: result.brand,
|
||||
articleNumber: result.articleNumber,
|
||||
price: allOffers.length > 0 ? Math.min(...allOffers.map(offer => offer.sortPrice)) : undefined
|
||||
}) : getMetaByPath('/card');
|
||||
|
||||
// Генерируем микроразметку Product
|
||||
const productSchema = useMemo(() => {
|
||||
if (!result || allOffers.length === 0) return null;
|
||||
|
||||
const schemaProduct: SchemaOrgProduct = {
|
||||
name: result.name,
|
||||
description: `${result.brand} ${result.articleNumber} - ${result.name}`,
|
||||
brand: result.brand,
|
||||
sku: result.articleNumber,
|
||||
image: mainImageUrl || (result?.partsIndexImages && result.partsIndexImages.length > 0 ? result.partsIndexImages[0].url : undefined),
|
||||
category: "Автозапчасти",
|
||||
offers: allOffers.map(offer => ({
|
||||
price: offer.sortPrice,
|
||||
currency: "RUB",
|
||||
availability: convertAvailability(offer.quantity || 0),
|
||||
seller: offer.type === 'internal' ? 'Protek' : 'AutoEuro',
|
||||
deliveryTime: offer.deliveryTime ? `${offer.deliveryTime} дней` : undefined,
|
||||
warehouse: offer.warehouse || 'Склад'
|
||||
}))
|
||||
};
|
||||
|
||||
return generateProductSchema(schemaProduct);
|
||||
}, [result, allOffers, mainImageUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Загрузка товара - Protek</title>
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title="Загрузка товара - Protek"
|
||||
description="Загрузка информации о товаре..."
|
||||
/>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
@ -211,14 +245,14 @@ export default function CardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{result ? `${result.brand} ${result.articleNumber} - ${result.name}` : `Карточка товара`} - Protek</title>
|
||||
<meta name="description" content={`Подробная информация о товаре ${result?.name}`} />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
{productSchema && <JsonLdScript schema={productSchema} />}
|
||||
<InfoCard
|
||||
brand={result ? result.brand : brandQuery}
|
||||
articleNumber={result ? result.articleNumber : searchQuery}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
@ -10,15 +11,17 @@ import CartSummary2 from "../components/CartSummary2";
|
||||
import MobileMenuBottomSection from "../components/MobileMenuBottomSection";
|
||||
|
||||
export default function CartStep2() {
|
||||
const metaConfig = getMetaByPath('/cart-step-2');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Cart Step 2</title>
|
||||
<meta name="description" content="Cart Step 2" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
|
||||
<CartInfo />
|
||||
<section className="main">
|
||||
|
@ -8,19 +8,16 @@ import CartRecommended from "../components/CartRecommended";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import React, { useState } from "react";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
export default function CartPage() {
|
||||
const [step, setStep] = useState(1);
|
||||
const metaData = getMetaByPath('/cart');
|
||||
|
||||
return (
|
||||
<><Head>
|
||||
<title>Cart</title>
|
||||
<meta name="description" content="Cart" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
|
||||
<CartInfo />
|
||||
|
||||
|
@ -23,6 +23,11 @@ import { useProductPrices } from '@/hooks/useProductPrices';
|
||||
import { PriceSkeleton } from '@/components/skeletons/ProductListSkeleton';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import toast from 'react-hot-toast';
|
||||
import CartIcon from '@/components/CartIcon';
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath, createCategoryMeta } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateBreadcrumbSchema, generateWebSiteSchema } from "@/lib/schema";
|
||||
|
||||
const mockData = Array(12).fill({
|
||||
image: "",
|
||||
@ -505,16 +510,28 @@ export default function Catalog() {
|
||||
return <div className="py-8 text-center">Загрузка фильтров...</div>;
|
||||
}
|
||||
|
||||
// Определяем meta-теги для каталога
|
||||
const categoryNameDecoded = decodeURIComponent(categoryName as string || 'Каталог');
|
||||
const metaData = createCategoryMeta(categoryNameDecoded, visibleProductsCount || undefined);
|
||||
|
||||
// Генерируем микроразметку для каталога
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: "Главная", url: "https://protek.ru/" },
|
||||
{ name: "Каталог", url: "https://protek.ru/catalog" },
|
||||
...(categoryName ? [{ name: categoryNameDecoded, url: `https://protek.ru/catalog?categoryName=${categoryName}` }] : [])
|
||||
]);
|
||||
|
||||
const websiteSchema = generateWebSiteSchema(
|
||||
"Protek - Каталог автозапчастей",
|
||||
"https://protek.ru",
|
||||
"https://protek.ru/search"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Catalog</title>
|
||||
<meta name="description" content="Catalog" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<JsonLdScript schema={breadcrumbSchema} />
|
||||
<JsonLdScript schema={websiteSchema} />
|
||||
<CatalogInfoHeader
|
||||
title={
|
||||
isPartsAPIMode ? decodeURIComponent(categoryName as string || 'Запчасти') :
|
||||
@ -720,7 +737,7 @@ export default function Catalog() {
|
||||
productId={entity.id}
|
||||
artId={entity.id}
|
||||
offerKey={priceData?.offerKey}
|
||||
onAddToCart={() => {
|
||||
onAddToCart={async () => {
|
||||
// Если цена не загружена, загружаем её и добавляем в корзину
|
||||
if (!priceData && !isLoadingPriceData) {
|
||||
loadPriceOnDemand(productForPrice);
|
||||
@ -740,6 +757,7 @@ export default function Catalog() {
|
||||
price: priceData.price,
|
||||
currency: priceData.currency || 'RUB',
|
||||
quantity: 1,
|
||||
stock: undefined, // информация о наличии не доступна для PartsIndex
|
||||
deliveryTime: '1-3 дня',
|
||||
warehouse: 'Parts Index',
|
||||
supplier: 'Parts Index',
|
||||
@ -747,10 +765,23 @@ export default function Catalog() {
|
||||
image: entity.images?.[0] || '',
|
||||
};
|
||||
|
||||
addItem(itemToAdd);
|
||||
const result = await addItem(itemToAdd);
|
||||
|
||||
// Показываем уведомление
|
||||
toast.success(`Товар "${entity.brand.name} ${entity.code}" добавлен в корзину за ${priceData.price.toLocaleString('ru-RU')} ₽`);
|
||||
if (result.success) {
|
||||
// Показываем уведомление
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${entity.brand.name} ${entity.code} за ${priceData.price.toLocaleString('ru-RU')} ₽`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: <CartIcon size={20} color="#fff" />,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
} else {
|
||||
toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.');
|
||||
}
|
||||
|
@ -1,14 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function Checkout() {
|
||||
const metaConfig = getMetaByPath('/checkout');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Checkout</title>
|
||||
<meta name="description" content="Checkout" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из checkout.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
|
@ -8,17 +8,21 @@ import InfoContacts from "@/components/contacts/InfoContacts";
|
||||
import MapContacts from "@/components/contacts/MapContacts";
|
||||
import OrderContacts from "@/components/contacts/OrderContacts";
|
||||
import LegalContacts from "@/components/contacts/LegalContacts";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateLocalBusinessSchema, PROTEK_LOCAL_BUSINESS } from "@/lib/schema";
|
||||
|
||||
const Contacts = () => (
|
||||
<>
|
||||
<Head>
|
||||
<title>Contacts</title>
|
||||
<meta name="description" content="Contacts" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
const Contacts = () => {
|
||||
const metaData = getMetaByPath('/contacts');
|
||||
|
||||
// Генерируем микроразметку LocalBusiness для страницы контактов
|
||||
const localBusinessSchema = generateLocalBusinessSchema(PROTEK_LOCAL_BUSINESS);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<JsonLdScript schema={localBusinessSchema} />
|
||||
<InfoContacts />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
@ -38,7 +42,8 @@ const Contacts = () => (
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contacts;
|
@ -1,14 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailCategory() {
|
||||
const metaConfig = getMetaByPath('/detail_category');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail Category</title>
|
||||
<meta name="description" content="Detail Category" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_category.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
|
@ -1,14 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailProduct() {
|
||||
const metaConfig = getMetaByPath('/detail_product');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail Product</title>
|
||||
<meta name="description" content="Detail Product" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_product.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
|
@ -1,14 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailSku() {
|
||||
const metaConfig = getMetaByPath('/detail_sku');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail SKU</title>
|
||||
<meta name="description" content="Detail SKU" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_sku.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
@ -18,6 +19,8 @@ export default function Favorite() {
|
||||
const [filterValues, setFilterValues] = useState<{[key: string]: any}>({});
|
||||
const [sortBy, setSortBy] = useState<'name' | 'brand' | 'date'>('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const metaConfig = getMetaByPath('/favorite');
|
||||
|
||||
// Создаем динамические фильтры на основе данных избранного
|
||||
const dynamicFilters: FilterConfig[] = useMemo(() => {
|
||||
@ -96,13 +99,13 @@ export default function Favorite() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Избранное - Protek Auto</title>
|
||||
<meta name="description" content="Ваши избранные товары" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<FavoriteInfo />
|
||||
<section className="main">
|
||||
|
||||
|
@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import NewsAndPromos from "@/components/index/NewsAndPromos";
|
||||
import Footer from "@/components/Footer";
|
||||
import IndexTopMenuNav from "@/components/index/IndexTopMenuNav";
|
||||
import ProductOfDaySection from "@/components/index/ProductOfDaySection";
|
||||
import CategoryNavSection from "@/components/index/CategoryNavSection";
|
||||
import BrandSelectionSection from "@/components/index/BrandSelectionSection";
|
||||
import BestPriceSection from "@/components/index/BestPriceSection";
|
||||
import TopSalesSection from "@/components/index/TopSalesSection";
|
||||
import PromoImagesSection from "@/components/index/PromoImagesSection";
|
||||
import NewArrivalsSection from '@/components/index/NewArrivalsSection';
|
||||
import SupportVinSection from '@/components/index/SupportVinSection';
|
||||
|
||||
export default function HomeNew () {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Home New</title>
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<IndexTopMenuNav />
|
||||
<ProductOfDaySection />
|
||||
<CategoryNavSection />
|
||||
<BrandSelectionSection />
|
||||
<BestPriceSection />
|
||||
<TopSalesSection />
|
||||
<PromoImagesSection />
|
||||
<NewArrivalsSection />
|
||||
<SupportVinSection />
|
||||
<NewsAndPromos />
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
59
src/pages/index-old.tsx
Normal file
59
src/pages/index-old.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import styles from "@/styles/Home.module.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import HeroSlider from "@/components/index/HeroSlider";
|
||||
import CatalogSection from "@/components/index/CatalogSection";
|
||||
import AvailableParts from "@/components/index/AvailableParts";
|
||||
import NewsAndPromos from "@/components/index/NewsAndPromos";
|
||||
import AboutHelp from "@/components/about/AboutHelp";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default function HomeOld() {
|
||||
const metaData = getMetaByPath('/');
|
||||
|
||||
// Генерируем микроразметку для главной страницы
|
||||
const organizationSchema = generateOrganizationSchema(PROTEK_ORGANIZATION);
|
||||
const websiteSchema = generateWebSiteSchema(
|
||||
"Protek - Автозапчасти и аксессуары",
|
||||
"https://protek.ru",
|
||||
"https://protek.ru/search"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<JsonLdScript schema={organizationSchema} />
|
||||
<JsonLdScript schema={websiteSchema} />
|
||||
<HeroSlider />
|
||||
<CatalogSection />
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<AboutHelp />
|
||||
</div>
|
||||
<AvailableParts />
|
||||
<NewsAndPromos />
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,44 +1,48 @@
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import styles from "@/styles/Home.module.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import HeroSlider from "@/components/index/HeroSlider";
|
||||
import CatalogSection from "@/components/index/CatalogSection";
|
||||
import AvailableParts from "@/components/index/AvailableParts";
|
||||
import NewsAndPromos from "@/components/index/NewsAndPromos";
|
||||
import AboutHelp from "@/components/about/AboutHelp";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import Footer from "@/components/Footer";
|
||||
import IndexTopMenuNav from "@/components/index/IndexTopMenuNav";
|
||||
import ProductOfDaySection from "@/components/index/ProductOfDaySection";
|
||||
import CategoryNavSection from "@/components/index/CategoryNavSection";
|
||||
import BrandSelectionSection from "@/components/index/BrandSelectionSection";
|
||||
import BestPriceSection from "@/components/index/BestPriceSection";
|
||||
import TopSalesSection from "@/components/index/TopSalesSection";
|
||||
import PromoImagesSection from "@/components/index/PromoImagesSection";
|
||||
import NewArrivalsSection from '@/components/index/NewArrivalsSection';
|
||||
import SupportVinSection from '@/components/index/SupportVinSection';
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
||||
|
||||
export default function Home() {
|
||||
const metaData = getMetaByPath('/');
|
||||
|
||||
// Генерируем микроразметку для главной страницы
|
||||
const organizationSchema = generateOrganizationSchema(PROTEK_ORGANIZATION);
|
||||
const websiteSchema = generateWebSiteSchema(
|
||||
"Protek - Автозапчасти и аксессуары",
|
||||
"https://protek.ru",
|
||||
"https://protek.ru/search"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Protek</title>
|
||||
<meta name="description" content="Protek" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<HeroSlider />
|
||||
<CatalogSection />
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<AboutHelp />
|
||||
</div>
|
||||
<AvailableParts />
|
||||
<MetaTags {...metaData} />
|
||||
<JsonLdScript schema={organizationSchema} />
|
||||
<JsonLdScript schema={websiteSchema} />
|
||||
{/* <IndexTopMenuNav /> */}
|
||||
<ProductOfDaySection />
|
||||
<CategoryNavSection />
|
||||
<BrandSelectionSection />
|
||||
<BestPriceSection />
|
||||
<TopSalesSection />
|
||||
<PromoImagesSection />
|
||||
<NewArrivalsSection />
|
||||
<SupportVinSection />
|
||||
<NewsAndPromos />
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
|
@ -6,23 +6,19 @@ import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import InfoNewsOpen from "@/components/news-open/InfoNewsOpen";
|
||||
import ContentNews from "@/components/news-open/ContentNews";
|
||||
import NewsCard from "@/components/news/NewsCard";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
export default function NewsOpen() {
|
||||
const metaData = getMetaByPath('/news-open');
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<Head>
|
||||
<title>news open</title>
|
||||
<meta content="news open" property="og:title" />
|
||||
<meta content="news open" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
<link href="/css/normalize.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/protekproject.webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoNewsOpen />
|
||||
<section className="main">
|
||||
|
@ -1,4 +1,5 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
@ -8,16 +9,17 @@ import NewsCard from "@/components/news/NewsCard";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
|
||||
export default function News() {
|
||||
const metaConfig = getMetaByPath('/news');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>News</title>
|
||||
<meta name="description" content="News" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<InfoNews />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
|
@ -1,14 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function OrderConfirmation() {
|
||||
const metaConfig = getMetaByPath('/order-confirmation');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Order Confirmation</title>
|
||||
<meta name="description" content="Order Confirmation" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из order-confirmation.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../../components/MetaTags";
|
||||
import { getMetaByPath } from "../../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
@ -30,16 +31,17 @@ export default function PaymentCancelled() {
|
||||
router.push('/catalog');
|
||||
};
|
||||
|
||||
const metaConfig = getMetaByPath('/payment/cancelled');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Оплата отменена - Protekauto</title>
|
||||
<meta name="description" content="Оплата заказа была отменена" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
|
||||
<Header />
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../../components/MetaTags";
|
||||
import { getMetaByPath } from "../../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
@ -40,16 +41,17 @@ export default function PaymentFailed() {
|
||||
router.push('/catalog');
|
||||
};
|
||||
|
||||
const metaConfig = getMetaByPath('/payment/failed');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Ошибка оплаты - Protekauto</title>
|
||||
<meta name="description" content="Произошла ошибка при оплате заказа" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
|
||||
<Header />
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import MetaTags from "../../components/MetaTags";
|
||||
import { getMetaByPath } from "../../lib/meta-config";
|
||||
|
||||
const InvoicePage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@ -20,12 +21,17 @@ const InvoicePage: React.FC = () => {
|
||||
router.push('/profile/orders');
|
||||
};
|
||||
|
||||
const metaConfig = getMetaByPath('/payment/invoice');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Счёт на оплату - Протек Авто</title>
|
||||
<meta name="description" content="Счёт на оплату заказа" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
|
||||
<div className="w-layout-vflex" style={{
|
||||
minHeight: '100vh',
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMutation, ApolloProvider } from "@apollo/client";
|
||||
import { gql } from "@apollo/client";
|
||||
import { apolloClient } from "@/lib/apollo";
|
||||
import toast from "react-hot-toast";
|
||||
import MetaTags from "../../components/MetaTags";
|
||||
import { getMetaByPath } from "../../lib/meta-config";
|
||||
|
||||
const CONFIRM_PAYMENT = gql`
|
||||
mutation ConfirmPayment($orderId: ID!) {
|
||||
@ -74,17 +75,6 @@ function PaymentSuccessContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Оплата прошла успешно - Protekauto</title>
|
||||
<meta name="description" content="Ваш заказ успешно оплачен" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
|
||||
|
||||
<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">
|
||||
@ -211,9 +201,20 @@ function PaymentSuccessContent() {
|
||||
}
|
||||
|
||||
export default function PaymentSuccess() {
|
||||
const metaConfig = getMetaByPath('/payment/success');
|
||||
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<PaymentSuccessContent />
|
||||
</ApolloProvider>
|
||||
<>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<PaymentSuccessContent />
|
||||
</ApolloProvider>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
@ -10,12 +11,17 @@ import DeliveryInfo from "@/components/payments/DeliveryInfo";
|
||||
import PaymentsCompony from "@/components/payments/PaymentsCompony";
|
||||
|
||||
export default function PaymentsMethod() {
|
||||
const metaConfig = getMetaByPath('/payments-method');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Payments Method</title>
|
||||
<meta name="description" content="Payments Method" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<InfoPayments />
|
||||
|
||||
<section className="main">
|
||||
|
177
src/pages/privacy-policy.tsx
Normal file
177
src/pages/privacy-policy.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Политика конфиденциальности | ПротекАвто</title>
|
||||
<meta name="description" content="Политика конфиденциальности интернет-магазина автозапчастей ПротекАвто" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white rounded-lg shadow-sm p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-950 mb-8">
|
||||
Политика конфиденциальности
|
||||
</h1>
|
||||
|
||||
<div className="prose prose-gray max-w-none space-y-6">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
1. Общие положения
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Настоящая Политика конфиденциальности определяет порядок обработки и защиты персональных данных
|
||||
пользователей интернет-магазина ПротекАвто (далее — «Сайт»). Мы уважаем вашу конфиденциальность
|
||||
и стремимся защитить ваши персональные данные.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
2. Сбор и использование персональных данных
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Мы собираем следующие категории персональных данных:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 space-y-2">
|
||||
<li>Контактная информация (имя, телефон, email)</li>
|
||||
<li>Данные для доставки (адрес, индекс)</li>
|
||||
<li>Информация о заказах и покупках</li>
|
||||
<li>Техническая информация (IP-адрес, браузер, устройство)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
3. Файлы cookie
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Наш сайт использует файлы cookie для улучшения пользовательского опыта. Мы используем следующие типы cookie:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Необходимые cookie</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Обеспечивают базовую функциональность сайта, включая корзину покупок, авторизацию и безопасность.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Аналитические cookie</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Помогают нам понять, как посетители используют сайт, чтобы улучшить его работу.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Маркетинговые cookie</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Используются для показа релевантной рекламы и отслеживания эффективности рекламных кампаний.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-950 mb-2">Функциональные cookie</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Обеспечивают расширенную функциональность и персонализацию сайта.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
4. Цели обработки данных
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Мы обрабатываем ваши персональные данные для следующих целей:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 space-y-2">
|
||||
<li>Обработка и выполнение заказов</li>
|
||||
<li>Связь с клиентами по вопросам заказов</li>
|
||||
<li>Улучшение качества обслуживания</li>
|
||||
<li>Анализ использования сайта</li>
|
||||
<li>Маркетинговые коммуникации (с вашего согласия)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
5. Передача данных третьим лицам
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Мы не продаем и не передаем ваши персональные данные третьим лицам, за исключением случаев,
|
||||
необходимых для выполнения наших обязательств перед вами (доставка, оплата) или требований законодательства.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
6. Защита данных
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Мы применяем современные технические и организационные меры для защиты ваших персональных данных
|
||||
от несанкционированного доступа, изменения, раскрытия или уничтожения.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
7. Ваши права
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Вы имеете право:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 space-y-2">
|
||||
<li>Получить информацию о обработке ваших данных</li>
|
||||
<li>Внести изменения в ваши данные</li>
|
||||
<li>Удалить ваши данные</li>
|
||||
<li>Ограничить обработку данных</li>
|
||||
<li>Отозвать согласие на обработку данных</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
8. Контактная информация
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
По вопросам обработки персональных данных вы можете обратиться к нам:
|
||||
</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg mt-4">
|
||||
<p className="text-gray-600">
|
||||
<strong>Email:</strong> privacy@protekauto.ru<br />
|
||||
<strong>Телефон:</strong> +7 (495) 123-45-67<br />
|
||||
<strong>Адрес:</strong> г. Москва, ул. Примерная, д. 1
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-950 mb-4">
|
||||
9. Изменения в политике
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Мы оставляем за собой право вносить изменения в настоящую Политику конфиденциальности.
|
||||
Актуальная версия всегда доступна на данной странице.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
Последнее обновление: {new Date().toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
@ -12,7 +12,8 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileActsMain from '@/components/profile/ProfileActsMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import NotificationMane from "@/components/profile/NotificationMane";
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
|
||||
const ProfileActsPage = () => {
|
||||
const router = useRouter();
|
||||
@ -59,13 +60,13 @@ const ProfileActsPage = () => {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>ProfileActs</title>
|
||||
<meta content="ProfileActs" property="og:title" />
|
||||
<meta content="ProfileActs" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={getMetaByPath('/profile-acts').title}
|
||||
description={getMetaByPath('/profile-acts').description}
|
||||
keywords={getMetaByPath('/profile-acts').keywords}
|
||||
ogTitle={getMetaByPath('/profile-acts').ogTitle}
|
||||
ogDescription={getMetaByPath('/profile-acts').ogDescription}
|
||||
/>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
|
@ -7,19 +7,17 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileAddressesMain from '@/components/profile/ProfileAddressesMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
|
||||
const ProfileAddressesPage = () => {
|
||||
const metaData = getMetaByPath('/profile-addresses');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileAddresses</title>
|
||||
<meta content="ProfileAddresses" property="og:title" />
|
||||
<meta content="ProfileAddresses" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
|
@ -10,7 +10,8 @@ import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileBalanceMain from '@/components/profile/ProfileBalanceMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
|
||||
const ProfileBalancePage = () => {
|
||||
const router = useRouter();
|
||||
@ -56,15 +57,17 @@ const ProfileBalancePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const metaConfig = getMetaByPath('/profile-balance');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileBalance</title>
|
||||
<meta content="ProfileBalance" property="og:title" />
|
||||
<meta content="ProfileBalance" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
|
34
src/pages/profile-cookie-settings.tsx
Normal file
34
src/pages/profile-cookie-settings.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import CookieSettings from '@/components/profile/CookieSettings';
|
||||
|
||||
const ProfileCookieSettingsPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Настройки cookies | Личный кабинет | ПротекАвто</title>
|
||||
<meta name="description" content="Управление настройками файлов cookie в личном кабинете" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex gap-8 max-md:flex-col">
|
||||
{/* Боковое меню */}
|
||||
<div className="w-80 max-md:w-full">
|
||||
<LKMenu />
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1">
|
||||
<CookieSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCookieSettingsPage;
|
@ -7,19 +7,17 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileGarageMain from '@/components/profile/ProfileGarageMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
|
||||
const ProfileGaragePage = () => {
|
||||
const metaData = getMetaByPath('/profile-gar');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileGarage</title>
|
||||
<meta content="ProfileGarage" property="og:title" />
|
||||
<meta content="ProfileGarage" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
|
@ -7,6 +7,8 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileHistoryMain from '@/components/profile/ProfileHistoryMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
|
||||
@ -29,15 +31,11 @@ const ProfileHistoryPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const metaData = getMetaByPath('/profile-history');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>ProfileHistory</title>
|
||||
<meta content="ProfileHistory" property="og:title" />
|
||||
<meta content="ProfileHistory" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5 h-full flex-1">
|
||||
|
@ -8,20 +8,18 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileOrdersMain from '@/components/profile/ProfileOrdersMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
|
||||
|
||||
const ProfileOrdersPage = () => {
|
||||
const metaData = getMetaByPath('/profile-orders');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileOrders</title>
|
||||
<meta content="ProfileOrders" property="og:title" />
|
||||
<meta content="ProfileOrders" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
|
@ -10,7 +10,8 @@ import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileRequisitiesMain from '@/components/profile/ProfileRequisitiesMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
|
||||
const ProfileRequisitiesPage = () => {
|
||||
const router = useRouter();
|
||||
@ -55,15 +56,18 @@ const ProfileRequisitiesPage = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metaConfig = getMetaByPath('/profile-req');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileRequisities</title>
|
||||
<meta content="ProfileRequisities" property="og:title" />
|
||||
<meta content="ProfileRequisities" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={metaConfig.title}
|
||||
description={metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
|
@ -7,19 +7,17 @@ import LKMenu from '@/components/LKMenu';
|
||||
import ProfileSettingsMain from '@/components/profile/ProfileSettingsMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
|
||||
const ProfileSettingsPage = () => {
|
||||
const metaData = getMetaByPath('/profile-set');
|
||||
|
||||
return (
|
||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
||||
<Head>
|
||||
<title>ProfileHistory</title>
|
||||
<meta content="ProfileSettings" property="og:title" />
|
||||
<meta content="ProfileSettings" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
|
36
src/pages/robots.txt.ts
Normal file
36
src/pages/robots.txt.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const robotsTxt = `User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Запрещаем индексацию служебных страниц
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /dashboard/
|
||||
Disallow: /_next/
|
||||
Disallow: /static/
|
||||
Disallow: /test-auth/
|
||||
|
||||
# Запрещаем индексацию страниц с параметрами
|
||||
Disallow: /*?*
|
||||
|
||||
# Разрешаем основные страницы
|
||||
Allow: /
|
||||
Allow: /catalog
|
||||
Allow: /about
|
||||
Allow: /contacts
|
||||
Allow: /news
|
||||
Allow: /brands
|
||||
Allow: /delivery
|
||||
Allow: /payment
|
||||
Allow: /wholesale
|
||||
Allow: /vin
|
||||
|
||||
# Указываем карту сайта
|
||||
Sitemap: https://protekauto.ru/sitemap.xml
|
||||
`
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain')
|
||||
res.status(200).send(robotsTxt)
|
||||
}
|
@ -16,6 +16,8 @@ import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
|
||||
import { SEARCH_PRODUCT_OFFERS, GET_ANALOG_OFFERS } from "@/lib/graphql";
|
||||
import { useArticleImage } from "@/hooks/useArticleImage";
|
||||
import { usePartsIndexEntityInfo } from "@/hooks/usePartsIndex";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { createProductMeta } from "@/lib/meta-config";
|
||||
|
||||
const ANALOGS_CHUNK_SIZE = 5;
|
||||
|
||||
@ -435,21 +437,21 @@ export default function SearchResult() {
|
||||
);
|
||||
}
|
||||
|
||||
// Создаем динамические meta-теги для товара
|
||||
const metaData = result ? createProductMeta({
|
||||
name: result.name,
|
||||
brand: result.brand,
|
||||
articleNumber: result.articleNumber,
|
||||
price: minPrice
|
||||
}) : {
|
||||
title: 'Результаты поиска - Protek',
|
||||
description: 'Результаты поиска автозапчастей. Найдите нужные запчасти среди широкого ассортимента.',
|
||||
keywords: 'результаты поиска, поиск запчастей, найти запчасти'
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{result ? `${result.brand} ${result.articleNumber} - ${result.name}` : `Результаты поиска`} - Protek</title>
|
||||
<meta name="description" content={`Лучшие предложения и аналоги для ${result?.name}`} />
|
||||
<meta content="Search result" property="og:title" />
|
||||
<meta content="Search result" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoSearch
|
||||
brand={result ? result.brand : brandQuery}
|
||||
articleNumber={result ? result.articleNumber : searchQuery}
|
||||
@ -606,12 +608,11 @@ export default function SearchResult() {
|
||||
return true; // Показываем загружающиеся аналоги
|
||||
}
|
||||
|
||||
const analogOffers = transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
);
|
||||
// Проверяем, есть ли предложения у аналога
|
||||
const hasInternalOffers = loadedAnalogData.internalOffers && loadedAnalogData.internalOffers.length > 0;
|
||||
const hasExternalOffers = loadedAnalogData.externalOffers && loadedAnalogData.externalOffers.length > 0;
|
||||
|
||||
// Показываем аналог только если у него есть предложения
|
||||
return analogOffers.length > 0;
|
||||
return hasInternalOffers || hasExternalOffers;
|
||||
});
|
||||
|
||||
// Если нет аналогов с предложениями, не показываем секцию
|
||||
@ -625,11 +626,79 @@ export default function SearchResult() {
|
||||
const analogKey = `${analog.brand}-${analog.articleNumber}`;
|
||||
const loadedAnalogData = loadedAnalogs[analogKey];
|
||||
|
||||
const analogOffers = loadedAnalogData
|
||||
? transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
)
|
||||
: [];
|
||||
// Если данные аналога загружены, формируем предложения из всех его данных
|
||||
const analogOffers = loadedAnalogData ? (() => {
|
||||
const allAnalogOffers: any[] = [];
|
||||
|
||||
// Добавляем внутренние предложения
|
||||
if (loadedAnalogData.internalOffers) {
|
||||
loadedAnalogData.internalOffers.forEach((offer: any) => {
|
||||
allAnalogOffers.push({
|
||||
...offer,
|
||||
type: 'internal',
|
||||
brand: loadedAnalogData.brand,
|
||||
articleNumber: loadedAnalogData.articleNumber,
|
||||
name: loadedAnalogData.name,
|
||||
isAnalog: true,
|
||||
deliveryDuration: offer.deliveryDays
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляем внешние предложения
|
||||
if (loadedAnalogData.externalOffers) {
|
||||
loadedAnalogData.externalOffers.forEach((offer: any) => {
|
||||
allAnalogOffers.push({
|
||||
...offer,
|
||||
type: 'external',
|
||||
brand: offer.brand || loadedAnalogData.brand,
|
||||
articleNumber: offer.code || loadedAnalogData.articleNumber,
|
||||
name: offer.name || loadedAnalogData.name,
|
||||
isAnalog: true,
|
||||
deliveryDuration: offer.deliveryTime
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Применяем фильтры только если они активны
|
||||
const filteredAnalogOffers = allAnalogOffers.filter(offer => {
|
||||
// Фильтр по бренду
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(offer.brand)) {
|
||||
return false;
|
||||
}
|
||||
// Фильтр по цене
|
||||
if (priceRange && (offer.price < priceRange[0] || offer.price > priceRange[1])) {
|
||||
return false;
|
||||
}
|
||||
// Фильтр по сроку доставки
|
||||
if (deliveryRange) {
|
||||
const deliveryDays = offer.deliveryDuration;
|
||||
if (deliveryDays < deliveryRange[0] || deliveryDays > deliveryRange[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Фильтр по количеству наличия
|
||||
if (quantityRange) {
|
||||
const quantity = offer.quantity;
|
||||
if (quantity < quantityRange[0] || quantity > quantityRange[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Фильтр по поисковой строке
|
||||
if (filterSearchTerm) {
|
||||
const searchTerm = filterSearchTerm.toLowerCase();
|
||||
const brandMatch = offer.brand.toLowerCase().includes(searchTerm);
|
||||
const articleMatch = offer.articleNumber.toLowerCase().includes(searchTerm);
|
||||
const nameMatch = offer.name.toLowerCase().includes(searchTerm);
|
||||
if (!brandMatch && !articleMatch && !nameMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return transformOffersForCard(filteredAnalogOffers);
|
||||
})() : [];
|
||||
|
||||
return (
|
||||
<CoreProductCard
|
||||
@ -647,20 +716,7 @@ export default function SearchResult() {
|
||||
{(() => {
|
||||
// Проверяем, есть ли еще аналоги с предложениями для загрузки
|
||||
const remainingAnalogs = result.analogs.slice(visibleAnalogsCount);
|
||||
const hasMoreAnalogsWithOffers = remainingAnalogs.some((analog: any) => {
|
||||
const analogKey = `${analog.brand}-${analog.articleNumber}`;
|
||||
const loadedAnalogData = loadedAnalogs[analogKey];
|
||||
|
||||
if (!loadedAnalogData) {
|
||||
return true; // Могут быть предложения у незагруженных аналогов
|
||||
}
|
||||
|
||||
const analogOffers = transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
);
|
||||
|
||||
return analogOffers.length > 0;
|
||||
});
|
||||
const hasMoreAnalogsWithOffers = remainingAnalogs.length > 0;
|
||||
|
||||
return hasMoreAnalogsWithOffers && (
|
||||
<div className="w-layout-hflex pagination">
|
||||
|
@ -7,6 +7,8 @@ import Footer from '@/components/Footer';
|
||||
import MobileMenuBottomSection from '@/components/MobileMenuBottomSection';
|
||||
import { DOC_FIND_OEM, FIND_LAXIMO_VEHICLES_BY_PART_NUMBER } from '@/lib/graphql';
|
||||
import { LaximoDocFindOEMResult, LaximoVehiclesByPartResult, LaximoVehicleSearchResult } from '@/types/laximo';
|
||||
import MetaTags from '@/components/MetaTags';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
type SearchMode = 'parts' | 'vehicles';
|
||||
|
||||
@ -98,14 +100,11 @@ const SearchPage = () => {
|
||||
const hasPartsResults = partsResult && partsResult.details && partsResult.details.length > 0;
|
||||
const hasVehiclesResults = vehiclesResult && vehiclesResult.totalVehicles > 0;
|
||||
|
||||
const metaData = getMetaByPath('/search');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск по артикулу {searchQuery} - Protek</title>
|
||||
<meta name="description" content={`Результаты поиска по артикулу ${searchQuery}`} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoSearch />
|
||||
<div className="page-wrapper bg-[#F5F8FB] min-h-screen">
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
85
src/pages/sitemap.xml.ts
Normal file
85
src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const baseUrl = 'https://protekauto.ru'
|
||||
|
||||
const staticPages = [
|
||||
{
|
||||
url: '',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: '/about',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: '/contacts',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: '/catalog',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: '/news',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: '/brands',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: '/delivery',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: '/payment',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: '/wholesale',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: '/vin',
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${staticPages
|
||||
.map(
|
||||
(page) => ` <url>
|
||||
<loc>${baseUrl}${page.url}</loc>
|
||||
<lastmod>${page.lastModified}</lastmod>
|
||||
<changefreq>${page.changeFrequency}</changefreq>
|
||||
<priority>${page.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
|
||||
res.setHeader('Content-Type', 'application/xml')
|
||||
res.status(200).send(sitemap)
|
||||
}
|
@ -5,23 +5,19 @@ import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import Link from 'next/link';
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
export default function ThankYouPage() {
|
||||
const metaData = getMetaByPath('/thankyoupage');
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<Head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>thankyoupage</title>
|
||||
<meta content="thankyoupage" property="og:title" />
|
||||
<meta content="thankyoupage" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js" type="text/javascript"></script>
|
||||
<script type="text/javascript" dangerouslySetInnerHTML={{__html: `WebFont.load({ google: { families: [\"Onest:regular,600,700,800,900:cyrillic-ext,latin\"] }});`}} />
|
||||
<script type="text/javascript" dangerouslySetInnerHTML={{__html: `!function(o,c){var n=c.documentElement,t=\" w-mod-\";n.className+=t+\"js\",(\"ontouchstart\"in o||o.DocumentTouch&&c instanceof DocumentTouch)&&(n.className+=t+\"touch\")}(window,document);`}} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<ThankInfo />
|
||||
|
@ -7,6 +7,8 @@ import Footer from '@/components/Footer';
|
||||
import VehicleSearchSection from '../../components/VehicleSearchSection';
|
||||
import { GET_LAXIMO_CATALOG_INFO } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo } from '@/types/laximo';
|
||||
import MetaTags from '@/components/MetaTags';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
const VehicleSearchPage = () => {
|
||||
const router = useRouter();
|
||||
@ -69,12 +71,11 @@ const VehicleSearchPage = () => {
|
||||
|
||||
const catalogInfo = catalogData.laximoCatalogInfo;
|
||||
|
||||
const metaData = getMetaByPath('/vehicle-search');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск автомобиля - {catalogInfo.name}</title>
|
||||
<meta name="description" content={`Поиск автомобилей ${catalogInfo.name} для подбора запчастей`} />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<Header />
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
|
||||
import Footer from '@/components/Footer';
|
||||
import Layout from '@/components/Layout';
|
||||
import VehiclePartsSearchSection from '@/components/VehiclePartsSearchSection';
|
||||
@ -19,6 +19,8 @@ import KnotParts from '@/components/vin/KnotParts';
|
||||
import VinQuick from '@/components/vin/VinQuick';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from '@/components/MobileMenuBottomSection';
|
||||
import MetaTags from '@/components/MetaTags';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
|
||||
interface LaximoVehicleInfo {
|
||||
@ -58,6 +60,7 @@ const VehicleDetailsPage = () => {
|
||||
const [showKnot, setShowKnot] = useState(false);
|
||||
const [foundParts, setFoundParts] = useState<any[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly');
|
||||
const [openedPath, setOpenedPath] = useState<string[]>([]);
|
||||
const [searchState, setSearchState] = useState<{
|
||||
loading: boolean;
|
||||
error: any;
|
||||
@ -71,32 +74,7 @@ const VehicleDetailsPage = () => {
|
||||
});
|
||||
const [selectedNode, setSelectedNode] = useState<any | null>(null);
|
||||
const [selectedQuickGroup, setSelectedQuickGroup] = useState<any | null>(null);
|
||||
const handleCategoryClick = (e?: React.MouseEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setShowKnot(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('link-2')) {
|
||||
e.preventDefault();
|
||||
setShowKnot(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, []);
|
||||
// ====== КОНЕЦ ХУКОВ ======
|
||||
|
||||
// Получаем информацию о каталоге
|
||||
const { data: catalogData } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
|
||||
GET_LAXIMO_CATALOG_INFO,
|
||||
{
|
||||
variables: { catalogCode: brand },
|
||||
skip: !brand
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Получаем информацию о выбранном автомобиле
|
||||
const ssdFromQuery = Array.isArray(router.query.ssd) ? router.query.ssd[0] : router.query.ssd;
|
||||
const useStorage = router.query.use_storage === '1';
|
||||
@ -115,9 +93,16 @@ const VehicleDetailsPage = () => {
|
||||
} else if (ssdFromQuery && ssdFromQuery.trim() !== '') {
|
||||
finalSsd = ssdFromQuery;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Получаем информацию о каталоге
|
||||
const { data: catalogData } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
|
||||
GET_LAXIMO_CATALOG_INFO,
|
||||
{
|
||||
variables: { catalogCode: brand },
|
||||
skip: !brand
|
||||
}
|
||||
);
|
||||
|
||||
const { data: vehicleData, loading: vehicleLoading, error: vehicleError } = useQuery<{ laximoVehicleInfo: LaximoVehicleInfo }>(
|
||||
GET_LAXIMO_VEHICLE_INFO,
|
||||
{
|
||||
@ -152,6 +137,27 @@ const VehicleDetailsPage = () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем детали выбранного узла, если он выбран
|
||||
const {
|
||||
data: unitDetailsData,
|
||||
loading: unitDetailsLoading,
|
||||
error: unitDetailsError
|
||||
} = useQuery(
|
||||
GET_LAXIMO_UNIT_DETAILS,
|
||||
{
|
||||
variables: selectedNode
|
||||
? {
|
||||
catalogCode: selectedNode.catalogCode || selectedNode.catalog || brand,
|
||||
vehicleId: selectedNode.vehicleId || vehicleId,
|
||||
unitId: selectedNode.unitid || selectedNode.unitId,
|
||||
ssd: selectedNode.ssd || finalSsd || '',
|
||||
}
|
||||
: { catalogCode: '', vehicleId: '', unitId: '', ssd: '' },
|
||||
skip: !selectedNode,
|
||||
errorPolicy: 'all',
|
||||
}
|
||||
);
|
||||
|
||||
// Автоматическое перенаправление на правильный vehicleId если API вернул другой ID
|
||||
useEffect(() => {
|
||||
if (vehicleData?.laximoVehicleInfo && vehicleData.laximoVehicleInfo.vehicleid !== vehicleId) {
|
||||
@ -178,27 +184,54 @@ const VehicleDetailsPage = () => {
|
||||
return;
|
||||
}
|
||||
}, [vehicleData, vehicleId, brand, finalSsd, router]);
|
||||
|
||||
// Получаем детали выбранного узла, если он выбран
|
||||
const {
|
||||
data: unitDetailsData,
|
||||
loading: unitDetailsLoading,
|
||||
error: unitDetailsError
|
||||
} = useQuery(
|
||||
GET_LAXIMO_UNIT_DETAILS,
|
||||
{
|
||||
variables: selectedNode
|
||||
? {
|
||||
catalogCode: selectedNode.catalogCode || selectedNode.catalog || brand,
|
||||
vehicleId: selectedNode.vehicleId || vehicleId,
|
||||
unitId: selectedNode.unitid || selectedNode.unitId,
|
||||
ssd: selectedNode.ssd || finalSsd || '',
|
||||
}
|
||||
: { catalogCode: '', vehicleId: '', unitId: '', ssd: '' },
|
||||
skip: !selectedNode,
|
||||
errorPolicy: 'all',
|
||||
|
||||
// Следим за изменением quickgroup в URL и обновляем selectedQuickGroup
|
||||
useEffect(() => {
|
||||
const quickgroupId = router.query.quickgroup as string;
|
||||
if (quickgroupId) {
|
||||
// Используем функциональное обновление состояния для избежания dependency
|
||||
setSelectedQuickGroup((prev: any) => {
|
||||
if (prev && prev.quickgroupid === quickgroupId) return prev;
|
||||
return { quickgroupid: quickgroupId };
|
||||
});
|
||||
} else {
|
||||
setSelectedQuickGroup(null);
|
||||
}
|
||||
);
|
||||
}, [router.query.quickgroup]);
|
||||
|
||||
// Следить за изменением unitid в URL и обновлять selectedNode
|
||||
useEffect(() => {
|
||||
const unitid = router.query.unitid as string;
|
||||
if (unitid) {
|
||||
// Используем функциональное обновление состояния для избежания dependency
|
||||
setSelectedNode((prev: any) => {
|
||||
if (prev && (prev.unitid === unitid || prev.id === unitid)) return prev;
|
||||
return { unitid };
|
||||
});
|
||||
} else {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
}, [router.query.unitid]);
|
||||
|
||||
const handleCategoryClick = (e?: React.MouseEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setShowKnot(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('link-2')) {
|
||||
e.preventDefault();
|
||||
setShowKnot(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, []);
|
||||
// ====== КОНЕЦ ХУКОВ ======
|
||||
|
||||
|
||||
const unitDetails = unitDetailsData?.laximoUnitDetails || [];
|
||||
|
||||
// Логируем ошибки
|
||||
@ -209,9 +242,10 @@ const VehicleDetailsPage = () => {
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Загрузка автомобиля...</title>
|
||||
</Head>
|
||||
<MetaTags
|
||||
title="Загрузка автомобиля..."
|
||||
description="Загружаем информацию об автомобиле..."
|
||||
/>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
@ -226,9 +260,10 @@ const VehicleDetailsPage = () => {
|
||||
if (!catalogData?.laximoCatalogInfo) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Каталог не найден</title>
|
||||
</Head>
|
||||
<MetaTags
|
||||
title="Каталог не найден"
|
||||
description="Информация о каталоге недоступна"
|
||||
/>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Каталог не найден</h1>
|
||||
@ -289,16 +324,84 @@ const VehicleDetailsPage = () => {
|
||||
const hasError = vehicleError && !vehicleData?.laximoVehicleInfo;
|
||||
const catalogInfo = catalogData.laximoCatalogInfo;
|
||||
|
||||
// Создаем динамические meta-теги
|
||||
const vehicleName = vehicleInfo.brand && vehicleInfo.name
|
||||
? (vehicleInfo.name.indexOf(vehicleInfo.brand) !== 0
|
||||
? `${vehicleInfo.brand} ${vehicleInfo.name}`
|
||||
: vehicleInfo.name)
|
||||
: 'Автомобиль';
|
||||
|
||||
const metaData = {
|
||||
title: `Запчасти для ${vehicleName} - Поиск по каталогу Protek`,
|
||||
description: `Найдите и купите запчасти для ${vehicleName}. Широкий выбор оригинальных и аналоговых запчастей с быстрой доставкой.`,
|
||||
keywords: `запчасти ${vehicleName}, ${vehicleInfo.brand} запчасти, автозапчасти, каталог запчастей`,
|
||||
ogTitle: `Запчасти для ${vehicleName} - Protek`,
|
||||
ogDescription: `Найдите и купите запчасти для ${vehicleName}. Широкий выбор оригинальных и аналоговых запчастей.`
|
||||
};
|
||||
|
||||
// --- Синхронизация selectedQuickGroup с URL ---
|
||||
// Функция для открытия VinQuick и добавления quickgroup в URL
|
||||
const openQuickGroup = (group: any) => {
|
||||
// Проверяем что group не null и имеет quickgroupid
|
||||
if (!group || !group.quickgroupid) {
|
||||
console.warn('⚠️ openQuickGroup: получен null или группа без quickgroupid:', group);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedQuickGroup(group);
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: { ...router.query, quickgroup: group.quickgroupid } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
// --- Сброс VinQuick (selectedQuickGroup) и quickgroup в URL ---
|
||||
const closeQuickGroup = () => {
|
||||
setSelectedQuickGroup(null);
|
||||
const { quickgroup, ...rest } = router.query;
|
||||
if (quickgroup) {
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Синхронизация selectedNode (KnotIn) с URL ---
|
||||
// Открыть KnotIn и добавить unitid в URL
|
||||
const openKnot = (node: any) => {
|
||||
// ОТЛАДКА: Логируем узел который получили
|
||||
console.log('🔍 [vehicleId].tsx openKnot получил узел:', {
|
||||
unitId: node.unitid,
|
||||
unitName: node.name,
|
||||
hasSsd: !!node.ssd,
|
||||
nodeSsd: node.ssd ? `${node.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
vehicleSsd: vehicleInfo.ssd ? `${vehicleInfo.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: node.ssd?.length || 0
|
||||
});
|
||||
|
||||
setSelectedNode(node);
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: { ...router.query, unitid: node.unitid || node.id } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
// Закрыть KnotIn и удалить unitid из URL
|
||||
const closeKnot = () => {
|
||||
setSelectedNode(null);
|
||||
const { unitid, ...rest } = router.query;
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>VIN</title>
|
||||
<meta content="vin" property="og:title" />
|
||||
<meta content="vin" property="twitter:title" />
|
||||
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
|
||||
{/* ====== ВРЕМЕННЫЙ МАКЕТ ДЛЯ ВЕРСТКИ (начало) ====== */}
|
||||
<InfoVin
|
||||
@ -326,9 +429,28 @@ const VehicleDetailsPage = () => {
|
||||
setFoundParts(results);
|
||||
setSearchState({ loading, error, query, isSearching: isSearching || false });
|
||||
}}
|
||||
onNodeSelect={setSelectedNode}
|
||||
onActiveTabChange={(tab) => setActiveTab(tab)}
|
||||
onQuickGroupSelect={setSelectedQuickGroup}
|
||||
onNodeSelect={openKnot}
|
||||
onActiveTabChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
// Сбрасываем состояние при смене вкладки
|
||||
setSelectedQuickGroup(null);
|
||||
setSelectedNode(null);
|
||||
setOpenedPath([]);
|
||||
// Очищаем URL от параметров quickgroup и unitid
|
||||
const { quickgroup, unitid, ...rest } = router.query;
|
||||
if (quickgroup || unitid) {
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
}
|
||||
}}
|
||||
onQuickGroupSelect={openQuickGroup}
|
||||
activeTab={activeTab}
|
||||
openedPath={openedPath}
|
||||
setOpenedPath={setOpenedPath}
|
||||
onCloseQuickGroup={closeQuickGroup}
|
||||
/>
|
||||
{searchState.isSearching ? (
|
||||
<div className="knot-parts">
|
||||
@ -388,17 +510,19 @@ const VehicleDetailsPage = () => {
|
||||
catalogCode={vehicleInfo.catalog}
|
||||
vehicleId={vehicleInfo.vehicleid}
|
||||
ssd={vehicleInfo.ssd}
|
||||
onBack={() => setSelectedQuickGroup(null)}
|
||||
onNodeSelect={setSelectedNode}
|
||||
onBack={closeQuickGroup}
|
||||
onNodeSelect={openKnot}
|
||||
/>
|
||||
) : (
|
||||
<VinCategory
|
||||
catalogCode={vehicleInfo.catalog}
|
||||
vehicleId={vehicleInfo.vehicleid}
|
||||
ssd={vehicleInfo.ssd}
|
||||
onNodeSelect={setSelectedNode}
|
||||
onNodeSelect={openKnot}
|
||||
activeTab={activeTab}
|
||||
onQuickGroupSelect={setSelectedQuickGroup}
|
||||
onQuickGroupSelect={openQuickGroup}
|
||||
openedPath={openedPath}
|
||||
setOpenedPath={setOpenedPath}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -408,10 +532,22 @@ const VehicleDetailsPage = () => {
|
||||
<div className="w-layout-hflex flex-block-13">
|
||||
<div className="w-layout-vflex flex-block-14-copy-copy">
|
||||
{/* <button onClick={() => setSelectedNode(null)} style={{ marginBottom: 16 }}>Назад</button> */}
|
||||
{/* ОТЛАДКА: Логируем передачу SSD в KnotIn */}
|
||||
{(() => {
|
||||
const knotSsd = selectedNode.ssd || vehicleInfo.ssd;
|
||||
console.log('🔍 [vehicleId].tsx передает в KnotIn:', {
|
||||
selectedNodeSsd: selectedNode.ssd ? `${selectedNode.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
vehicleInfoSsd: vehicleInfo.ssd ? `${vehicleInfo.ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
finalSsd: knotSsd ? `${knotSsd.substring(0, 50)}...` : 'отсутствует',
|
||||
unitId: selectedNode.unitid,
|
||||
unitName: selectedNode.name
|
||||
});
|
||||
return null;
|
||||
})()}
|
||||
<KnotIn
|
||||
catalogCode={vehicleInfo.catalog}
|
||||
vehicleId={vehicleInfo.vehicleid}
|
||||
ssd={vehicleInfo.ssd}
|
||||
ssd={selectedNode.ssd || vehicleInfo.ssd}
|
||||
unitId={selectedNode.unitid}
|
||||
unitName={selectedNode.name}
|
||||
parts={unitDetails}
|
||||
|
@ -6,6 +6,8 @@ import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { GET_LAXIMO_CATALOG_INFO, SEARCH_LAXIMO_OEM } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo, LaximoOEMResult } from '@/types/laximo';
|
||||
import MetaTags from '@/components/MetaTags';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
const InfoPartDetail = ({ brandName, oemNumber }: { brandName: string; oemNumber: string }) => (
|
||||
<section className="section-info">
|
||||
@ -121,12 +123,11 @@ const PartDetailPage = () => {
|
||||
const totalDetails = oemResult?.categories.reduce((total, cat) =>
|
||||
total + cat.units.reduce((unitTotal, unit) => unitTotal + unit.details.length, 0), 0) || 0;
|
||||
|
||||
const metaData = getMetaByPath('/vehicle-search');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Деталь {oemNumber} - {catalogInfo?.name || 'Каталог запчастей'}</title>
|
||||
<meta name="description" content={`Подробная информация о детали ${oemNumber} в каталоге ${catalogInfo?.name}`} />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<Header />
|
||||
<div className="bg-[#F5F8FB] min-h-screen w-full">
|
||||
<InfoPartDetail brandName={catalogInfo?.name || String(brand)} oemNumber={String(oemNumber)} />
|
||||
|
@ -7,6 +7,8 @@ import Footer from '@/components/Footer';
|
||||
import MobileMenuBottomSection from '@/components/MobileMenuBottomSection';
|
||||
import { GET_BRANDS_BY_CODE, GET_LAXIMO_CATALOG_INFO } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo } from '@/types/laximo';
|
||||
import MetaTags from '@/components/MetaTags';
|
||||
import { getMetaByPath } from '@/lib/meta-config';
|
||||
|
||||
const InfoBrandSelection = ({
|
||||
brandName,
|
||||
@ -115,14 +117,11 @@ const BrandSelectionPage = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
const metaData = getMetaByPath('/vehicle-search');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Выбор производителя для {oemNumber} - {catalogInfo?.name || 'Каталог запчастей'}</title>
|
||||
<meta name="description" content={`Выберите производителя для детали ${oemNumber} в каталоге ${catalogInfo?.name}`} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoBrandSelection
|
||||
brandName={catalogInfo?.name || String(brand)}
|
||||
oemNumber={String(oemNumber)}
|
||||
|
@ -6,6 +6,8 @@ import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { FIND_LAXIMO_VEHICLES_BY_PART_NUMBER } from '@/lib/graphql';
|
||||
import { LaximoVehiclesByPartResult, LaximoVehicleSearchResult } from '@/types/laximo';
|
||||
import MetaTags from "../components/MetaTags";
|
||||
import { getMetaByPath } from "../lib/meta-config";
|
||||
|
||||
const VehiclesByPartPage = () => {
|
||||
const router = useRouter();
|
||||
@ -46,12 +48,15 @@ const VehiclesByPartPage = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const metaConfig = getMetaByPath('/vehicles-by-part');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск автомобилей по артикулу {cleanPartNumber} - Protek</title>
|
||||
</Head>
|
||||
<MetaTags
|
||||
title="Поиск автомобилей по артикулу - Protek"
|
||||
description="Поиск автомобилей, в которых используется деталь..."
|
||||
/>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
@ -66,9 +71,10 @@ const VehiclesByPartPage = () => {
|
||||
if (error || !data?.laximoFindVehiclesByPartNumber) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Ошибка поиска - Protek</title>
|
||||
</Head>
|
||||
<MetaTags
|
||||
title="Ошибка поиска - Protek"
|
||||
description="Произошла ошибка при поиске автомобилей по артикулу"
|
||||
/>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-4">
|
||||
@ -100,10 +106,13 @@ const VehiclesByPartPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Автомобили по артикулу {cleanPartNumber} - Protek</title>
|
||||
<meta name="description" content={`Найдено ${result.totalVehicles} автомобилей по артикулу ${cleanPartNumber} в ${result.catalogs.length} каталогах`} />
|
||||
</Head>
|
||||
<MetaTags
|
||||
title={cleanPartNumber ? `Автомобили по артикулу ${cleanPartNumber} - Protek` : metaConfig.title}
|
||||
description={cleanPartNumber ? `Поиск автомобилей, в которых используется деталь с артикулом ${cleanPartNumber}` : metaConfig.description}
|
||||
keywords={metaConfig.keywords}
|
||||
ogTitle={metaConfig.ogTitle}
|
||||
ogDescription={metaConfig.ogDescription}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
|
@ -10,6 +10,8 @@ import VinKnot from "@/components/vin/VinKnot";
|
||||
import KnotParts from "@/components/vin/KnotParts";
|
||||
import React, { useState } from "react";
|
||||
import KnotIn from "@/components/vin/KnotIn";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
export default function Vin() {
|
||||
@ -34,21 +36,15 @@ export default function Vin() {
|
||||
return () => document.removeEventListener("click", handler);
|
||||
}, []);
|
||||
|
||||
const metaData = getMetaByPath('/vin-step-2');
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<Head>
|
||||
<title>VIN</title>
|
||||
<meta content="vin" property="og:title" />
|
||||
<meta content="vin" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
<link href="/css/normalize.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/protekproject.webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoVin />
|
||||
<section className="main">
|
||||
|
@ -8,6 +8,8 @@ import VinLeftbar from "@/components/vin/VinLeftbar";
|
||||
import VinCategory from "@/components/vin/VinCategory";
|
||||
import VinKnot from "@/components/vin/VinKnot";
|
||||
import React, { useState } from "react";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
export default function Vin() {
|
||||
const [showKnot, setShowKnot] = useState(false);
|
||||
@ -31,18 +33,11 @@ export default function Vin() {
|
||||
return () => document.removeEventListener("click", handler);
|
||||
}, []);
|
||||
|
||||
const metaData = getMetaByPath('/vin');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>VIN</title>
|
||||
<meta content="vin" property="og:title" />
|
||||
<meta content="vin" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
<InfoVin />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container-vin w-container">
|
||||
|
@ -9,25 +9,16 @@ import WhyWholesale from "@/components/wholesale/WhyWholesale";
|
||||
import ServiceWholesale from "@/components/wholesale/ServiceWholesale";
|
||||
import HowToBuy from "@/components/wholesale/HowToBuy";
|
||||
import Help from "@/components/Help";
|
||||
import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
|
||||
|
||||
export default function Wholesale() {
|
||||
const metaData = getMetaByPath('/wholesale');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>wholesale</title>
|
||||
<meta content="wholesale" property="og:title" />
|
||||
<meta content="wholesale" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
<link href="/css/normalize.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/protekproject.webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<MetaTags {...metaData} />
|
||||
|
||||
<InfoWholesale />
|
||||
<section className="main">
|
||||
|
@ -84,4 +84,20 @@ input[type=number]::-webkit-outer-spin-button {
|
||||
}
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Анимация для cookie consent */
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-consent-enter {
|
||||
animation: slideInFromBottom 0.3s ease-out;
|
||||
}
|
@ -3,6 +3,32 @@
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.dropdown-scroll-invisible {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE и Edge */
|
||||
}
|
||||
.dropdown-scroll-invisible::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.dropdown-scroll-invisible {
|
||||
padding-left: 0 !important;
|
||||
list-style: none !important;
|
||||
}
|
||||
|
||||
.bottom_head {
|
||||
background-color: var(--back);
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* margin-top: -15px; */
|
||||
padding-left: 60px;
|
||||
padding-right: 60px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bottom_head{
|
||||
z-index: 60;
|
||||
}
|
||||
@ -154,7 +180,7 @@ input.text-block-31 {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.form-block, .text-field.w-input {
|
||||
.form-block {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
@ -469,7 +495,13 @@ input#VinSearchInput {
|
||||
|
||||
|
||||
.dropdown-toggle-card {
|
||||
align-self: stretch;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding: 6px 15px;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.dropdown-link-3 {
|
||||
@ -480,11 +512,17 @@ input#VinSearchInput {
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.dropdown-toggle-3.active, .dropdown-toggle-card.active {
|
||||
.dropdown-toggle-3.active{
|
||||
background-color: var(--background);
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dropdown-toggle-card.active {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dropdown-toggle-3.active {
|
||||
border-left: 2px solid var(--red);
|
||||
|
||||
@ -526,10 +564,13 @@ input#VinSearchInput {
|
||||
margin-top: 0;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 991px) {
|
||||
.bottom_head {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 479px) {
|
||||
.bottom_head {
|
||||
@ -538,18 +579,7 @@ input#VinSearchInput {
|
||||
}
|
||||
}
|
||||
|
||||
.bottom_head {
|
||||
background-color: var(--back);
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: -15px;
|
||||
padding-left: 60px;
|
||||
padding-right: 60px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
--_fonts---font-family: Onest, sans-serif;
|
||||
@ -605,13 +635,59 @@ body {
|
||||
position: absolute;
|
||||
}
|
||||
.mobile-menu-buttom-section {
|
||||
z-index: 1900;
|
||||
z-index: 1900 !important;
|
||||
background-color: var(--white);
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: auto 0% 0%;
|
||||
}
|
||||
|
||||
|
||||
.knot-parts {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 991px) {
|
||||
.flex-block-108, .flex-block-14-copy-copy {
|
||||
flex-flow: column;
|
||||
justify-content: space-between;
|
||||
|
||||
}
|
||||
.flex-block-14-copy-copy{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
}
|
||||
@media screen and (max-width: 991px) {
|
||||
.flex-block-118 {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch; /* или center, если нужно по центру */
|
||||
gap: 20px; /* если нужен отступ между блоками */
|
||||
}
|
||||
.flex-block-119 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.mobile-menu-buttom-section {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 991px) {
|
||||
.div-block-128 {
|
||||
height: 140px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
.div-block-128 {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.nav-menu-3 {
|
||||
z-index: 1900;
|
||||
background-color: #0000;
|
||||
@ -689,4 +765,115 @@ body {
|
||||
}
|
||||
.flex-block-108::-webkit-scrollbar, .flex-block-108-copy::-webkit-scrollbar, .w-layout-hflex.flex-block-121::-webkit-scrollbar, .core-product-search::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.flex-block-44 {
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.text-block-21 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flex-block-45 {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.flex-block-39 {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.heading-9-copy {
|
||||
min-width: 100px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.flex-block-15-copy {
|
||||
width: 235px!important;
|
||||
min-width: 235px!important;
|
||||
}
|
||||
|
||||
.nameitembp {
|
||||
flex: 0 auto;
|
||||
align-self: auto;
|
||||
height: 60px;
|
||||
width: 130px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media screen and (max-width: 991px) {
|
||||
.flex-block-15-copy {
|
||||
grid-column-gap: 5px;
|
||||
grid-row-gap: 5px;
|
||||
width: 160px !important;
|
||||
min-width: 160px !important;
|
||||
padding: 15px;
|
||||
}
|
||||
.div-block-3 {
|
||||
height: 102px !important;
|
||||
}
|
||||
.div-block-3.bp-item-info {
|
||||
height: 90px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 479px) {
|
||||
.flex-block-15-copy {
|
||||
grid-column-gap: 5px;
|
||||
grid-row-gap: 5px;
|
||||
width: 160px !important;
|
||||
min-width: 160px !important;
|
||||
padding: 15px;
|
||||
}
|
||||
.nameitembp {
|
||||
height: 36px;
|
||||
width: 95px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.knot-parts {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topmenub {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 479px) {
|
||||
.bestpriceitem {
|
||||
grid-column-gap: 5px;
|
||||
grid-row-gap: 5px;
|
||||
min-width: 160px;
|
||||
max-width: 190px;
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 1440px) {
|
||||
.image-27 {
|
||||
margin-bottom: -212px;
|
||||
margin-left: 800px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.topnav.w-nav {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.code-embed-15.w-embed {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6897,7 +6897,7 @@ body {
|
||||
}
|
||||
|
||||
.image-27 {
|
||||
margin-bottom: -218px;
|
||||
margin-bottom: -212px;
|
||||
margin-left: 800px;
|
||||
}
|
||||
}
|
||||
@ -10095,13 +10095,13 @@ body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-block-15-copy {
|
||||
/* .flex-block-15-copy {
|
||||
grid-column-gap: 5px;
|
||||
grid-row-gap: 5px;
|
||||
min-width: 135px;
|
||||
max-width: 190px;
|
||||
padding: 15px;
|
||||
}
|
||||
} */
|
||||
|
||||
.flex-block-14-copy-copy {
|
||||
grid-column-gap: 10px;
|
||||
@ -10521,7 +10521,7 @@ body {
|
||||
.brandsortb {
|
||||
grid-column-gap: 30px;
|
||||
grid-row-gap: 30px;
|
||||
padding-top: 0;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ export interface CartItem {
|
||||
originalPrice?: number
|
||||
currency: string
|
||||
quantity: number
|
||||
stock?: string | number // количество товара в наличии на складе
|
||||
deliveryTime?: string
|
||||
deliveryDate?: string
|
||||
warehouse?: string
|
||||
@ -52,7 +53,7 @@ export interface CartState {
|
||||
|
||||
export interface CartContextType {
|
||||
state: CartState
|
||||
addItem: (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => void
|
||||
addItem: (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => Promise<{ success: boolean; error?: string }>
|
||||
removeItem: (id: string) => void
|
||||
updateQuantity: (id: string, quantity: number) => void
|
||||
toggleSelect: (id: string) => void
|
||||
@ -64,4 +65,5 @@ export interface CartContextType {
|
||||
removeSelected: () => void
|
||||
updateDelivery: (delivery: Partial<DeliveryInfo>) => void
|
||||
clearCart: () => void
|
||||
clearError: () => void
|
||||
}
|
Reference in New Issue
Block a user