Compare commits
15 Commits
fix1407
...
72a9772934
Author | SHA1 | Date | |
---|---|---|---|
72a9772934 | |||
1da9c6ac09 | |||
649ddbfa8a | |||
f3d21959c9 | |||
61b50d10ba | |||
ea76106caa | |||
b7edd73ce0 | |||
b6f9d017d6 | |||
27d378154f | |||
5fd2cf1b8c | |||
2703137ca1 | |||
3e98f8fed6 | |||
9c152501db | |||
074eb120b4 | |||
4dfc081214 |
@ -10,6 +10,7 @@ services:
|
|||||||
NEXT_PUBLIC_UPLOAD_URL: ${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload}
|
NEXT_PUBLIC_UPLOAD_URL: ${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload}
|
||||||
NEXT_PUBLIC_MAINTENANCE_MODE: ${NEXT_PUBLIC_MAINTENANCE_MODE:-false}
|
NEXT_PUBLIC_MAINTENANCE_MODE: ${NEXT_PUBLIC_MAINTENANCE_MODE:-false}
|
||||||
NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${NEXT_PUBLIC_YANDEX_MAPS_API_KEY}
|
NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${NEXT_PUBLIC_YANDEX_MAPS_API_KEY}
|
||||||
|
PARTSAPI_URL: ${PARTSAPI_URL:-https://api.parts-index.com}
|
||||||
FRONTEND_PORT: ${FRONTEND_PORT:-3000}
|
FRONTEND_PORT: ${FRONTEND_PORT:-3000}
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
container_name: protekauto-frontend
|
container_name: protekauto-frontend
|
||||||
@ -26,6 +27,7 @@ services:
|
|||||||
- NEXT_PUBLIC_CMS_GRAPHQL_URL=${NEXT_PUBLIC_CMS_GRAPHQL_URL:-http://localhost:4000/graphql}
|
- NEXT_PUBLIC_CMS_GRAPHQL_URL=${NEXT_PUBLIC_CMS_GRAPHQL_URL:-http://localhost:4000/graphql}
|
||||||
- NEXT_PUBLIC_UPLOAD_URL=${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload}
|
- NEXT_PUBLIC_UPLOAD_URL=${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload}
|
||||||
- NEXT_PUBLIC_MAINTENANCE_MODE=${NEXT_PUBLIC_MAINTENANCE_MODE:-false}
|
- NEXT_PUBLIC_MAINTENANCE_MODE=${NEXT_PUBLIC_MAINTENANCE_MODE:-false}
|
||||||
|
- PARTSAPI_URL=${PARTSAPI_URL:-https://api.parts-index.com}
|
||||||
|
|
||||||
# Yandex Maps API
|
# Yandex Maps API
|
||||||
- NEXT_PUBLIC_YANDEX_MAPS_API_KEY=${NEXT_PUBLIC_YANDEX_MAPS_API_KEY}
|
- NEXT_PUBLIC_YANDEX_MAPS_API_KEY=${NEXT_PUBLIC_YANDEX_MAPS_API_KEY}
|
||||||
|
92
package-lock.json
generated
92
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -1540,6 +1541,15 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decamelize": {
|
"node_modules/decamelize": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
@ -1602,6 +1612,29 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
@ -1615,6 +1648,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@ -2140,6 +2185,44 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@ -2690,6 +2773,15 @@
|
|||||||
"uuid": "dist/esm/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which-module": {
|
"node_modules/which-module": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
BIN
public/images/noimage.png
Normal file
BIN
public/images/noimage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
@ -72,6 +72,10 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
|||||||
return parseFloat(cleanPrice) || 0;
|
return parseFloat(cleanPrice) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Note: BestPriceCard doesn't receive isInCart flags from backend
|
||||||
|
// Since it's a summary component, we'll remove cart state checking for now
|
||||||
|
const inCart = false; // Disabled for BestPriceCard
|
||||||
|
|
||||||
// Обработчик добавления в корзину
|
// Обработчик добавления в корзину
|
||||||
const handleAddToCart = async (e: React.MouseEvent) => {
|
const handleAddToCart = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -108,10 +112,14 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Показываем тоастер об успешном добавлении
|
// Показываем тоастер с разным текстом в зависимости от того, был ли товар уже в корзине
|
||||||
|
const toastMessage = inCart
|
||||||
|
? `Количество увеличено (+${count} шт.)`
|
||||||
|
: 'Товар добавлен в корзину!';
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
<div className="font-semibold" style={{ color: '#fff' }}>{toastMessage}</div>
|
||||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
|
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
|
||||||
</div>,
|
</div>,
|
||||||
{
|
{
|
||||||
@ -176,17 +184,55 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-42">
|
<div className="w-layout-hflex flex-block-42">
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
className="button-icon w-inline-block"
|
className={`button-icon w-inline-block ${inCart ? 'in-cart' : ''}`}
|
||||||
style={{ cursor: 'pointer', textDecoration: 'none' }}
|
style={{
|
||||||
aria-label="Добавить в корзину"
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
opacity: inCart ? 0.5 : 1,
|
||||||
|
backgroundColor: inCart ? '#9ca3af' : undefined
|
||||||
|
}}
|
||||||
|
aria-label={inCart ? "Товар уже в корзине" : "Добавить в корзину"}
|
||||||
|
title={inCart ? "Товар уже в корзине - нажмите для добавления еще" : "Добавить в корзину"}
|
||||||
>
|
>
|
||||||
<div className="div-block-26">
|
<div className="div-block-26">
|
||||||
<div className="icon-setting w-embed"><svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"/></svg></div>
|
<div
|
||||||
|
className="icon-setting w-embed"
|
||||||
|
style={{
|
||||||
|
filter: inCart ? 'brightness(0.7)' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{inCart && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '-8px',
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
fontSize: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
title="В корзине"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -122,7 +122,6 @@ const BestPriceItem: React.FC<BestPriceItemProps> = ({
|
|||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
image: image
|
image: image
|
||||||
});
|
});
|
||||||
toast.success('Товар добавлен в избранное');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,79 +1,54 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
|
|
||||||
const CartDebug: React.FC = () => {
|
const CartDebug: React.FC = () => {
|
||||||
const { state, addItem, clearCart } = useCart();
|
const { state, isInCart } = useCart();
|
||||||
const [debugInfo, setDebugInfo] = useState<any>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
if (typeof window !== 'undefined') {
|
return null;
|
||||||
const cartState = localStorage.getItem('cartState');
|
|
||||||
const cartSummaryState = localStorage.getItem('cartSummaryState');
|
|
||||||
const oldCart = localStorage.getItem('cart');
|
|
||||||
|
|
||||||
setDebugInfo({
|
|
||||||
cartState: cartState ? JSON.parse(cartState) : null,
|
|
||||||
cartSummaryState: cartSummaryState ? JSON.parse(cartSummaryState) : null,
|
|
||||||
oldCart: oldCart ? JSON.parse(oldCart) : null,
|
|
||||||
currentItems: state.items.length
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [state.items]);
|
|
||||||
|
|
||||||
const addTestItem = () => {
|
// Test the isInCart function with some example values from the cart
|
||||||
addItem({
|
const testItem = state.items[0];
|
||||||
name: 'Тестовый товар',
|
const testResult = testItem ? isInCart(testItem.productId, testItem.offerKey, testItem.article, testItem.brand) : false;
|
||||||
description: 'Описание тестового товара',
|
|
||||||
article: 'TEST123',
|
|
||||||
brand: 'TestBrand',
|
|
||||||
price: 1000,
|
|
||||||
currency: 'RUB',
|
|
||||||
quantity: 1,
|
|
||||||
image: '',
|
|
||||||
productId: 'test-product',
|
|
||||||
offerKey: 'test-offer',
|
|
||||||
isExternal: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStorage = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('cartState');
|
|
||||||
localStorage.removeItem('cartSummaryState');
|
|
||||||
localStorage.removeItem('cart');
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
right: '10px',
|
right: '10px',
|
||||||
background: 'white',
|
background: 'rgba(0,0,0,0.9)',
|
||||||
border: '1px solid #ccc',
|
color: 'white',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
borderRadius: '5px',
|
borderRadius: '5px',
|
||||||
maxWidth: '300px',
|
fontSize: '11px',
|
||||||
fontSize: '12px',
|
maxWidth: '350px',
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
}}>
|
maxHeight: '400px',
|
||||||
<h4>Cart Debug</h4>
|
overflow: 'auto'
|
||||||
<button onClick={addTestItem} style={{ marginBottom: '5px', marginRight: '5px' }}>
|
}}
|
||||||
Добавить товар
|
>
|
||||||
</button>
|
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>🛒 Cart Debug: {state.items.length} items</div>
|
||||||
<button onClick={clearCart} style={{ marginBottom: '5px', marginRight: '5px' }}>
|
{testItem && (
|
||||||
Очистить корзину
|
<div style={{ background: 'rgba(255,255,255,0.1)', padding: '5px', marginBottom: '5px', fontSize: '10px' }}>
|
||||||
</button>
|
<div>Testing isInCart for first item:</div>
|
||||||
<button onClick={clearStorage} style={{ marginBottom: '10px' }}>
|
<div>Brand: {testItem.brand}, Article: {testItem.article}</div>
|
||||||
Очистить localStorage
|
<div>Result: {testResult ? '✅ Found' : '❌ Not found'}</div>
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<strong>Товаров в корзине:</strong> {state.items.length}
|
|
||||||
</div>
|
</div>
|
||||||
<pre style={{ fontSize: '10px', maxHeight: '200px', overflow: 'auto' }}>
|
)}
|
||||||
{JSON.stringify(debugInfo, null, 2)}
|
{state.items.slice(0, 6).map((item, idx) => (
|
||||||
</pre>
|
<div key={idx} style={{ fontSize: '9px', marginTop: '3px', borderBottom: '1px solid rgba(255,255,255,0.2)', paddingBottom: '2px' }}>
|
||||||
|
{item.brand} {item.article}
|
||||||
|
{item.productId && <div style={{ color: '#90EE90' }}>PID: {item.productId.substring(0, 8)}...</div>}
|
||||||
|
{item.offerKey && <div style={{ color: '#87CEEB' }}>OK: {item.offerKey.substring(0, 15)}...</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{state.items.length > 6 && (
|
||||||
|
<div style={{ fontSize: '9px', marginTop: '3px', opacity: 0.7 }}>
|
||||||
|
...и еще {state.items.length - 6} товаров
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -17,6 +17,7 @@ interface CatalogProductCardProps {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
priceElement?: React.ReactNode; // Элемент для отображения цены (например, скелетон)
|
priceElement?: React.ReactNode; // Элемент для отображения цены (например, скелетон)
|
||||||
onAddToCart?: (e: React.MouseEvent) => void | Promise<void>;
|
onAddToCart?: (e: React.MouseEvent) => void | Promise<void>;
|
||||||
|
isInCart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
||||||
@ -34,6 +35,7 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
|||||||
currency = 'RUB',
|
currency = 'RUB',
|
||||||
priceElement,
|
priceElement,
|
||||||
onAddToCart,
|
onAddToCart,
|
||||||
|
isInCart = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
||||||
|
|
||||||
@ -150,7 +152,15 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Обновляем кнопку купить */}
|
{/* Обновляем кнопку купить */}
|
||||||
<div className="catc w-inline-block" onClick={handleBuyClick} style={{ cursor: 'pointer' }}>
|
<div
|
||||||
|
className="catc w-inline-block"
|
||||||
|
onClick={handleBuyClick}
|
||||||
|
style={{
|
||||||
|
cursor: isInCart ? 'default' : 'pointer',
|
||||||
|
opacity: isInCart ? 0.5 : 1,
|
||||||
|
filter: isInCart ? 'grayscale(1)' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="div-block-25">
|
<div className="div-block-25">
|
||||||
<div className="icon-setting w-embed">
|
<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">
|
<svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@ -158,7 +168,7 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-block-6">Купить</div>
|
<div className="text-block-6">{isInCart ? 'В корзине' : 'Купить'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,16 +3,17 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
interface CatalogSortDropdownProps {
|
interface CatalogSortDropdownProps {
|
||||||
active: number;
|
active: number;
|
||||||
onChange: (index: number) => void;
|
onChange: (index: number) => void;
|
||||||
|
options?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOptions = [
|
const defaultSortOptions = [
|
||||||
'По популярности',
|
'По популярности',
|
||||||
'Сначала дешевле',
|
'Сначала дешевле',
|
||||||
'Сначала дороже',
|
'Сначала дороже',
|
||||||
'Высокий рейтинг',
|
'Высокий рейтинг',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange }) => {
|
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange, options = defaultSortOptions }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onCha
|
|||||||
<div>Сортировка</div>
|
<div>Сортировка</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}>
|
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}>
|
||||||
{sortOptions.map((option, index) => (
|
{options.map((option: string, index: number) => (
|
||||||
<a
|
<a
|
||||||
key={index}
|
key={index}
|
||||||
href="#"
|
href="#"
|
||||||
|
28
src/components/CloseIcon.tsx
Normal file
28
src/components/CloseIcon.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CloseIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CloseIcon: React.FC<CloseIconProps> = ({ size = 20, color = '#fff' }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18 6L6 18M6 6L18 18"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CloseIcon;
|
@ -3,6 +3,7 @@ import { useCart } from "@/contexts/CartContext";
|
|||||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import CartIcon from "./CartIcon";
|
import CartIcon from "./CartIcon";
|
||||||
|
import { isDeliveryDate } from "@/lib/utils";
|
||||||
|
|
||||||
const INITIAL_OFFERS_LIMIT = 5;
|
const INITIAL_OFFERS_LIMIT = 5;
|
||||||
|
|
||||||
@ -20,6 +21,8 @@ interface CoreProductCardOffer {
|
|||||||
warehouse?: string;
|
warehouse?: string;
|
||||||
supplier?: string;
|
supplier?: string;
|
||||||
deliveryTime?: number;
|
deliveryTime?: number;
|
||||||
|
hasStock?: boolean;
|
||||||
|
isInCart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoreProductCardProps {
|
interface CoreProductCardProps {
|
||||||
@ -33,6 +36,7 @@ interface CoreProductCardProps {
|
|||||||
isLoadingOffers?: boolean;
|
isLoadingOffers?: boolean;
|
||||||
onLoadOffers?: () => void;
|
onLoadOffers?: () => void;
|
||||||
partsIndexPowered?: boolean;
|
partsIndexPowered?: boolean;
|
||||||
|
hasStock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||||
@ -45,11 +49,13 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
isAnalog = false,
|
isAnalog = false,
|
||||||
isLoadingOffers = false,
|
isLoadingOffers = false,
|
||||||
onLoadOffers,
|
onLoadOffers,
|
||||||
partsIndexPowered = false
|
partsIndexPowered = false,
|
||||||
|
hasStock = true
|
||||||
}) => {
|
}) => {
|
||||||
const { addItem } = useCart();
|
const { addItem } = useCart();
|
||||||
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
||||||
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
|
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
|
||||||
|
const [sortBy, setSortBy] = useState<'stock' | 'delivery' | 'price'>('price'); // Локальная сортировка для каждого товара
|
||||||
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
|
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
|
||||||
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
|
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
|
||||||
);
|
);
|
||||||
@ -63,8 +69,52 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
|
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
|
||||||
}, [offers.length]);
|
}, [offers.length]);
|
||||||
|
|
||||||
const displayedOffers = offers.slice(0, visibleOffersCount);
|
// Функция для парсинга цены из строки
|
||||||
const hasMoreOffers = visibleOffersCount < offers.length;
|
const parsePrice = (priceStr: string): number => {
|
||||||
|
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
|
||||||
|
return parseFloat(cleanPrice) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для парсинга количества в наличии
|
||||||
|
const parseStock = (stockStr: string): number => {
|
||||||
|
const match = stockStr.match(/\d+/);
|
||||||
|
return match ? parseInt(match[0]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для парсинга времени доставки
|
||||||
|
const parseDeliveryTime = (daysStr: string): string => {
|
||||||
|
// Если это дата (содержит название месяца), возвращаем как есть
|
||||||
|
if (isDeliveryDate(daysStr)) {
|
||||||
|
return daysStr;
|
||||||
|
}
|
||||||
|
// Иначе парсим как количество дней (для обратной совместимости)
|
||||||
|
const match = daysStr.match(/\d+/);
|
||||||
|
return match ? `${match[0]} дней` : daysStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция сортировки предложений
|
||||||
|
const sortOffers = (offers: CoreProductCardOffer[]) => {
|
||||||
|
const sorted = [...offers];
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'stock':
|
||||||
|
return sorted.sort((a, b) => parseStock(b.pcs) - parseStock(a.pcs));
|
||||||
|
case 'delivery':
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const aDelivery = a.deliveryTime || 999;
|
||||||
|
const bDelivery = b.deliveryTime || 999;
|
||||||
|
return aDelivery - bDelivery;
|
||||||
|
});
|
||||||
|
case 'price':
|
||||||
|
return sorted.sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||||||
|
default:
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedOffers = sortOffers(offers);
|
||||||
|
const displayedOffers = sortedOffers.slice(0, visibleOffersCount);
|
||||||
|
const hasMoreOffers = visibleOffersCount < sortedOffers.length;
|
||||||
|
|
||||||
// Проверяем, есть ли товар в избранном
|
// Проверяем, есть ли товар в избранном
|
||||||
const isItemFavorite = isFavorite(
|
const isItemFavorite = isFavorite(
|
||||||
@ -74,23 +124,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
brand
|
brand
|
||||||
);
|
);
|
||||||
|
|
||||||
// Функция для парсинга цены из строки
|
// Теперь используем isInCart флаг из backend вместо frontend проверки
|
||||||
const parsePrice = (priceStr: string): number => {
|
|
||||||
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
|
|
||||||
return parseFloat(cleanPrice) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция для парсинга времени доставки
|
|
||||||
const parseDeliveryTime = (daysStr: string): string => {
|
|
||||||
const match = daysStr.match(/\d+/);
|
|
||||||
return match ? `${match[0]} дней` : daysStr;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция для парсинга количества в наличии
|
|
||||||
const parseStock = (stockStr: string): number => {
|
|
||||||
const match = stockStr.match(/\d+/);
|
|
||||||
return match ? parseInt(match[0]) : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (idx: number, val: string) => {
|
const handleInputChange = (idx: number, val: string) => {
|
||||||
setInputValues(prev => ({ ...prev, [idx]: val }));
|
setInputValues(prev => ({ ...prev, [idx]: val }));
|
||||||
@ -126,6 +160,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
const handleAddToCart = async (offer: CoreProductCardOffer, index: number) => {
|
const handleAddToCart = async (offer: CoreProductCardOffer, index: number) => {
|
||||||
const quantity = quantities[index] || 1;
|
const quantity = quantities[index] || 1;
|
||||||
const availableStock = parseStock(offer.pcs);
|
const availableStock = parseStock(offer.pcs);
|
||||||
|
const inCart = offer.isInCart || false; // Use backend flag
|
||||||
|
|
||||||
const numericPrice = parsePrice(offer.price);
|
const numericPrice = parsePrice(offer.price);
|
||||||
|
|
||||||
@ -148,10 +183,14 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Показываем тоастер вместо alert
|
// Показываем тоастер с разным текстом в зависимости от того, был ли товар уже в корзине
|
||||||
|
const toastMessage = inCart
|
||||||
|
? `Количество увеличено (+${quantity} шт.)`
|
||||||
|
: 'Товар добавлен в корзину!';
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
|
<div className="font-semibold" style={{ color: '#fff' }}>{toastMessage}</div>
|
||||||
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${brand} ${article} (${quantity} шт.)`}</div>
|
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${brand} ${article} (${quantity} шт.)`}</div>
|
||||||
</div>,
|
</div>,
|
||||||
{
|
{
|
||||||
@ -228,7 +267,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
|
|
||||||
if (!offers || offers.length === 0) {
|
if (!offers || offers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-layout-hflex core-product-search-s1">
|
<div className={`w-layout-hflex core-product-search-s1 ${!hasStock ? 'out-of-stock-highlight' : ''}`} style={!hasStock ? { backgroundColor: '#fee', borderColor: '#f87171' } : {}}>
|
||||||
<div className="w-layout-vflex core-product-s1">
|
<div className="w-layout-vflex core-product-s1">
|
||||||
<div className="w-layout-vflex flex-block-47">
|
<div className="w-layout-vflex flex-block-47">
|
||||||
<div className="div-block-19">
|
<div className="div-block-19">
|
||||||
@ -238,6 +277,19 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
<div className="w-layout-hflex flex-block-79">
|
<div className="w-layout-hflex flex-block-79">
|
||||||
<h3 className="heading-10 name">{brand}</h3>
|
<h3 className="heading-10 name">{brand}</h3>
|
||||||
<h3 className="heading-10">{article}</h3>
|
<h3 className="heading-10">{article}</h3>
|
||||||
|
{!hasStock && (
|
||||||
|
<span className="out-of-stock-badge" style={{
|
||||||
|
backgroundColor: '#dc2626',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}>
|
||||||
|
Нет в наличии
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-block-21">{name}</div>
|
<div className="text-block-21">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -271,7 +323,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-layout-hflex core-product-search-s1">
|
<div className={`w-layout-hflex core-product-search-s1 ${!hasStock ? 'out-of-stock-highlight' : ''}`} style={!hasStock ? { backgroundColor: '#fee', borderColor: '#f87171' } : {}}>
|
||||||
<div className="w-layout-vflex flex-block-48-copy">
|
<div className="w-layout-vflex flex-block-48-copy">
|
||||||
<div className="w-layout-vflex product-list-search-s1">
|
<div className="w-layout-vflex product-list-search-s1">
|
||||||
|
|
||||||
@ -284,9 +336,22 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
|
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-vflex flex-block-50">
|
<div className="w-layout-vflex flex-block-50">
|
||||||
<div className="w-layout-hflex flex-block-79">
|
<div className="flex flex-row flex-nowrap items-center gap-2">
|
||||||
<h3 className="heading-10 name">{brand}</h3>
|
<h3 className="heading-10 name" style={{marginRight: 8}}>{brand}</h3>
|
||||||
<h3 className="heading-10">{article}</h3>
|
<h3 className="heading-10" style={{marginRight: 8}}>{article}</h3>
|
||||||
|
{!hasStock && (
|
||||||
|
<span className="out-of-stock-badge" style={{
|
||||||
|
backgroundColor: '#dc2626',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}>
|
||||||
|
Нет в наличии
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="favorite-icon w-embed"
|
className="favorite-icon w-embed"
|
||||||
onClick={handleFavoriteClick}
|
onClick={handleFavoriteClick}
|
||||||
@ -300,7 +365,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-block-21">{name}</div>
|
<div className="text-block-21 mt-1">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{image && (
|
{image && (
|
||||||
@ -316,14 +381,36 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex sort-list-s1">
|
<div className="w-layout-hflex sort-list-s1">
|
||||||
<div className="w-layout-hflex flex-block-49">
|
<div className="w-layout-hflex flex-block-49">
|
||||||
<div className="sort-item first">Наличие</div>
|
<div
|
||||||
<div className="sort-item">Доставка</div>
|
className={`sort-item first ${sortBy === 'stock' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSortBy('stock')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Наличие
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`sort-item ${sortBy === 'delivery' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSortBy('delivery')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Доставим
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`sort-item price ${sortBy === 'price' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSortBy('price')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Цена
|
||||||
</div>
|
</div>
|
||||||
<div className="sort-item price">Цена</div>
|
|
||||||
</div>
|
</div>
|
||||||
{displayedOffers.map((offer, idx) => {
|
{displayedOffers.map((offer, idx) => {
|
||||||
const isLast = idx === displayedOffers.length - 1;
|
const isLast = idx === displayedOffers.length - 1;
|
||||||
const maxCount = parseStock(offer.pcs);
|
const maxCount = parseStock(offer.pcs);
|
||||||
|
const inCart = offer.isInCart || false; // Use backend flag
|
||||||
|
|
||||||
|
// Backend now provides isInCart flag directly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-layout-hflex product-item-search-s1"
|
className="w-layout-hflex product-item-search-s1"
|
||||||
@ -393,17 +480,56 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAddToCart(offer, idx)}
|
onClick={() => handleAddToCart(offer, idx)}
|
||||||
className="button-icon w-inline-block"
|
className={`button-icon w-inline-block ${inCart ? 'in-cart' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{
|
||||||
aria-label="Добавить в корзину"
|
cursor: 'pointer',
|
||||||
|
opacity: inCart ? 0.5 : 1,
|
||||||
|
backgroundColor: inCart ? '#9ca3af' : undefined
|
||||||
|
}}
|
||||||
|
aria-label={inCart ? "Товар уже в корзине" : "Добавить в корзину"}
|
||||||
|
title={inCart ? "Товар уже в корзине - нажмите для добавления еще" : "Добавить в корзину"}
|
||||||
>
|
>
|
||||||
<div className="div-block-26">
|
<div className="div-block-26">
|
||||||
<img loading="lazy" src="/images/cart_icon.svg" alt="В корзину" className="image-11" />
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src="/images/cart_icon.svg"
|
||||||
|
alt={inCart ? "В корзине" : "В корзину"}
|
||||||
|
className="image-11"
|
||||||
|
style={{
|
||||||
|
filter: inCart ? 'brightness(0.7)' : undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{inCart && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8px',
|
||||||
|
right: '-8px',
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
fontSize: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
title="В корзине"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -414,7 +540,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
className="w-layout-hflex show-more-search"
|
className="w-layout-hflex show-more-search"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hasMoreOffers) {
|
if (hasMoreOffers) {
|
||||||
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
|
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
|
||||||
} else {
|
} else {
|
||||||
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
||||||
}
|
}
|
||||||
@ -422,11 +548,11 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
|
aria-label={hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
if (hasMoreOffers) {
|
if (hasMoreOffers) {
|
||||||
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
|
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
|
||||||
} else {
|
} else {
|
||||||
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
||||||
}
|
}
|
||||||
@ -434,7 +560,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-block-27">
|
<div className="text-block-27">
|
||||||
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'}
|
{hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть'}
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
src="/images/arrow_drop_down.svg"
|
src="/images/arrow_drop_down.svg"
|
||||||
|
@ -63,6 +63,7 @@ const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
|
|||||||
setLocalFilterValues({});
|
setLocalFilterValues({});
|
||||||
onSearchChange('');
|
onSearchChange('');
|
||||||
// Сбрасываем фильтры в родительском компоненте
|
// Сбрасываем фильтры в родительском компоненте
|
||||||
|
// Используем пустые массивы для правильной очистки
|
||||||
Object.keys(filterValues).forEach(key => {
|
Object.keys(filterValues).forEach(key => {
|
||||||
onFilterChange?.(key, []);
|
onFilterChange?.(key, []);
|
||||||
});
|
});
|
||||||
|
@ -115,7 +115,7 @@ const Footer = () => (
|
|||||||
<button className="bg-[#23407A] rounded-lg py-2 px-6 font-medium mt-1 mb-2">Напиши нам</button>
|
<button className="bg-[#23407A] rounded-lg py-2 px-6 font-medium mt-1 mb-2">Напиши нам</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Центр: меню */}
|
{/* Центр: меню */}
|
||||||
<div className="hidden md:flex flex-1 flex-wrap gap-10 justify-center min-w-[400px]">
|
<div className="hidden md:flex flex-1 flex-wrap gap-30 justify-center min-w-[400px]">
|
||||||
<div className="flex flex-col gap-3 min-w-[150px]">
|
<div className="flex flex-col gap-3 min-w-[150px]">
|
||||||
<div className="link">Подбор по марке авто</div>
|
<div className="link">Подбор по марке авто</div>
|
||||||
<a href="#" className="link">Поиск по VIN</a>
|
<a href="#" className="link">Поиск по VIN</a>
|
||||||
@ -178,7 +178,7 @@ const Footer = () => (
|
|||||||
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center md:flex-row md:items-start md:justify-center flex-1 flex-wrap gap-4 md:gap-20 md:mt-6 md:min-w-[400px]">
|
<div className="flex flex-col items-center md:flex-row md:items-start md:justify-center flex-1 flex-wrap gap-4 md:gap-37 md:mt-6 md:min-w-[400px]">
|
||||||
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Политика конфиденциальности</a>
|
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Политика конфиденциальности</a>
|
||||||
|
|
||||||
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Согласие на обработку персональных данных</a>
|
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Согласие на обработку персональных данных</a>
|
||||||
|
@ -9,6 +9,8 @@ import { FIND_LAXIMO_VEHICLE, DOC_FIND_OEM, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL,
|
|||||||
import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo';
|
import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import CartButton from './CartButton';
|
import CartButton from './CartButton';
|
||||||
|
import SearchHistoryDropdown from './SearchHistoryDropdown';
|
||||||
|
import { GET_RECENT_SEARCH_QUERIES, PartsSearchHistoryItem } from '@/lib/graphql/search-history';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onOpenAuthModal?: () => void;
|
onOpenAuthModal?: () => void;
|
||||||
@ -25,9 +27,14 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
|||||||
const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null);
|
const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null);
|
||||||
const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text');
|
const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text');
|
||||||
const [oemSearchMode, setOemSearchMode] = useState<'parts' | 'vehicles'>('parts');
|
const [oemSearchMode, setOemSearchMode] = useState<'parts' | 'vehicles'>('parts');
|
||||||
|
const [showSearchHistory, setShowSearchHistory] = useState(false);
|
||||||
|
const [searchHistoryItems, setSearchHistoryItems] = useState<PartsSearchHistoryItem[]>([]);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchFormRef = useRef<HTMLFormElement>(null);
|
const searchFormRef = useRef<HTMLFormElement>(null);
|
||||||
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isClient = useIsClient();
|
const isClient = useIsClient();
|
||||||
|
|
||||||
// Эффект для восстановления поискового запроса из URL
|
// Эффект для восстановления поискового запроса из URL
|
||||||
@ -111,11 +118,28 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Запрос для получения истории поиска
|
||||||
|
const [getSearchHistory, { loading: historyLoading }] = useLazyQuery(GET_RECENT_SEARCH_QUERIES, {
|
||||||
|
onCompleted: (data) => {
|
||||||
|
setSearchHistoryItems(data.partsSearchHistory?.items || []);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('❌ Ошибка загрузки истории поиска:', error);
|
||||||
|
setSearchHistoryItems([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Закрытие результатов при клике вне области
|
// Закрытие результатов при клике вне области
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
|
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
|
setShowSearchHistory(false);
|
||||||
|
setInputFocused(false);
|
||||||
|
// Показываем placeholder обратно только если поле пустое
|
||||||
|
if (searchQuery.trim() === '') {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -356,6 +380,54 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
|||||||
router.push(url);
|
router.push(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработчик фокуса на поле ввода
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setInputFocused(true);
|
||||||
|
setShowResults(false);
|
||||||
|
setShowPlaceholder(false);
|
||||||
|
if (searchQuery.trim() === '') {
|
||||||
|
setShowSearchHistory(true);
|
||||||
|
getSearchHistory({ variables: { limit: 5 } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик изменения значения поля ввода
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSearchQuery(value);
|
||||||
|
|
||||||
|
// Управляем placeholder в зависимости от наличия текста
|
||||||
|
if (value.trim() === '') {
|
||||||
|
setShowPlaceholder(false); // Скрываем placeholder пока в фокусе
|
||||||
|
setShowSearchHistory(true);
|
||||||
|
setShowResults(false);
|
||||||
|
getSearchHistory({ variables: { limit: 5 } });
|
||||||
|
} else {
|
||||||
|
setShowPlaceholder(false); // Скрываем placeholder когда есть текст
|
||||||
|
setShowSearchHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик потери фокуса
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
// Показываем placeholder обратно только если поле пустое
|
||||||
|
if (searchQuery.trim() === '') {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик клика по элементу истории
|
||||||
|
const handleHistoryItemClick = (searchQuery: string) => {
|
||||||
|
setSearchQuery(searchQuery);
|
||||||
|
setShowSearchHistory(false);
|
||||||
|
setInputFocused(false);
|
||||||
|
setShowPlaceholder(false); // Скрываем placeholder так как теперь есть текст
|
||||||
|
// Фокусируем поле ввода для возможности редактирования
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* <section className="top_head">
|
{/* <section className="top_head">
|
||||||
@ -421,7 +493,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
|||||||
</svg></div>
|
</svg></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="searcj w-form" style={{ position: 'relative' }}>
|
<div className="searcj w-form" style={{ position: 'relative' }} ref={searchDropdownRef}>
|
||||||
<form
|
<form
|
||||||
id="custom-search-form"
|
id="custom-search-form"
|
||||||
name="custom-search-form"
|
name="custom-search-form"
|
||||||
@ -444,23 +516,33 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
className="text-field w-input"
|
className="text-field w-input"
|
||||||
maxLength={256}
|
maxLength={256}
|
||||||
name="customSearch"
|
name="customSearch"
|
||||||
data-custom-input="true"
|
data-custom-input="true"
|
||||||
placeholder="Введите код запчасти, VIN номер или госномер автомобиля"
|
placeholder={showPlaceholder ? "Введите код запчасти, VIN номер или госномер автомобиля" : ""}
|
||||||
type="text"
|
type="text"
|
||||||
id="customSearchInput"
|
id="customSearchInput"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* История поиска */}
|
||||||
|
<SearchHistoryDropdown
|
||||||
|
isVisible={showSearchHistory && !showResults}
|
||||||
|
historyItems={searchHistoryItems}
|
||||||
|
onItemClick={handleHistoryItemClick}
|
||||||
|
loading={historyLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Результаты поиска VIN */}
|
{/* Результаты поиска VIN */}
|
||||||
{showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
|
{showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
|
||||||
<div
|
<div
|
||||||
ref={searchDropdownRef}
|
|
||||||
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-80 overflow-y-auto"
|
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-80 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="p-3 border-b border-gray-100">
|
<div className="p-3 border-b border-gray-100">
|
||||||
|
170
src/components/SearchHistoryDropdown.tsx
Normal file
170
src/components/SearchHistoryDropdown.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PartsSearchHistoryItem } from '@/lib/graphql/search-history';
|
||||||
|
|
||||||
|
interface SearchHistoryDropdownProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
historyItems: PartsSearchHistoryItem[];
|
||||||
|
onItemClick: (searchQuery: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
|
||||||
|
isVisible,
|
||||||
|
historyItems,
|
||||||
|
onItemClick,
|
||||||
|
loading = false
|
||||||
|
}) => {
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
// Фильтруем уникальные запросы
|
||||||
|
const uniqueQueries = Array.from(
|
||||||
|
new Map(
|
||||||
|
historyItems.map(item => [item.searchQuery.toLowerCase(), item])
|
||||||
|
).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSearchTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'VIN':
|
||||||
|
return 'VIN';
|
||||||
|
case 'PLATE':
|
||||||
|
return 'Госномер';
|
||||||
|
case 'OEM':
|
||||||
|
case 'ARTICLE':
|
||||||
|
return 'Артикул';
|
||||||
|
default:
|
||||||
|
return 'Поиск';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-history-dropdown-custom">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-gray-500">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Загрузка истории...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : uniqueQueries.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{uniqueQueries.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onItemClick(item.searchQuery)}
|
||||||
|
className="search-history-item-custom"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="search-history-icon-custom">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4l3 3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="search-history-inline">
|
||||||
|
<span className="search-history-query-custom">{item.searchQuery}</span>
|
||||||
|
<span className="search-history-type-custom">{getSearchTypeLabel(item.searchType)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-gray-500">
|
||||||
|
<p className="text-sm">История поиска пуста</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<style>{`
|
||||||
|
.search-history-dropdown-custom {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(44,62,80,0.10), 0 1.5px 4px rgba(44,62,80,0.08);
|
||||||
|
margin-top: 12px;
|
||||||
|
z-index: 50;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
padding: 6px 0;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE и Edge */
|
||||||
|
}
|
||||||
|
.search-history-dropdown-custom::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
.search-history-item-custom {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: background 0.18s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.search-history-item-custom:hover, .search-history-item-custom:focus {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.search-history-item-custom .flex {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
.search-history-icon-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #222;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.search-history-item-custom:hover .search-history-icon-custom,
|
||||||
|
.search-history-item-custom:focus .search-history-icon-custom {
|
||||||
|
background: #ec1c24;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.search-history-inline {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.search-history-query-custom {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #222;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.search-history-type-custom {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8e9aac;
|
||||||
|
margin: 0 0 0 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchHistoryDropdown;
|
@ -89,7 +89,6 @@ const TopSalesItem: React.FC<TopSalesItemProps> = ({
|
|||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
image: image
|
image: image
|
||||||
});
|
});
|
||||||
toast.success('Товар добавлен в избранное');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useCart } from "@/contexts/CartContext";
|
import { useCart } from "@/contexts/CartContext";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import CartIcon from "../CartIcon";
|
import CartIcon from "../CartIcon";
|
||||||
|
import { isDeliveryDate } from "@/lib/utils";
|
||||||
|
|
||||||
interface ProductBuyBlockProps {
|
interface ProductBuyBlockProps {
|
||||||
offer?: any;
|
offer?: any;
|
||||||
@ -51,7 +52,9 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
|||||||
brand: offer.brand,
|
brand: offer.brand,
|
||||||
article: offer.articleNumber,
|
article: offer.articleNumber,
|
||||||
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
|
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
|
||||||
deliveryTime: offer.deliveryTime ? String(offer.deliveryTime) + ' дней' : '1 день',
|
deliveryTime: offer.deliveryTime ? (typeof offer.deliveryTime === 'string' && isDeliveryDate(offer.deliveryTime)
|
||||||
|
? offer.deliveryTime
|
||||||
|
: String(offer.deliveryTime) + ' дней') : '1 день',
|
||||||
isExternal: offer.type === 'external'
|
isExternal: offer.type === 'external'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { isDeliveryDate } from "@/lib/utils";
|
||||||
|
|
||||||
interface ProductInfoProps {
|
interface ProductInfoProps {
|
||||||
offer?: any;
|
offer?: any;
|
||||||
@ -17,6 +18,11 @@ const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
|
|||||||
|
|
||||||
// Форматируем срок доставки
|
// Форматируем срок доставки
|
||||||
const formatDeliveryTime = (deliveryTime: number | string) => {
|
const formatDeliveryTime = (deliveryTime: number | string) => {
|
||||||
|
// Если это уже дата (содержит название месяца), возвращаем как есть
|
||||||
|
if (typeof deliveryTime === 'string' && isDeliveryDate(deliveryTime)) {
|
||||||
|
return deliveryTime;
|
||||||
|
}
|
||||||
|
|
||||||
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
|
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
|
||||||
|
|
||||||
if (!days || days === 0) {
|
if (!days || days === 0) {
|
||||||
|
@ -111,12 +111,62 @@ const BestPriceSection: React.FC = () => {
|
|||||||
<div className="text-block-58">Подборка лучших предложенийпо цене</div>
|
<div className="text-block-58">Подборка лучших предложенийпо цене</div>
|
||||||
<a href="#" className="button-24 w-button">Показать все</a>
|
<a href="#" className="button-24 w-button">Показать все</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel-row">
|
<div className="carousel-row" style={{ position: 'relative' }}>
|
||||||
|
{/* Стили для стрелок как в ProductOfDayBanner, но без абсолютного позиционирования */}
|
||||||
|
<style>{`
|
||||||
|
.carousel-arrow {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.carousel-arrow-left {}
|
||||||
|
.carousel-arrow-right {}
|
||||||
|
.carousel-arrow .arrow-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-circle,
|
||||||
|
.carousel-arrow:focus .arrow-circle {
|
||||||
|
background: #ec1c24;
|
||||||
|
}
|
||||||
|
.carousel-arrow .arrow-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
stroke: #222;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-svg,
|
||||||
|
.carousel-arrow:focus .arrow-svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.carousel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-layout-hflex flex-block-121 carousel-scroll" ref={scrollRef}>
|
<div className="w-layout-hflex flex-block-121 carousel-scroll" ref={scrollRef}>
|
||||||
{bestPriceItems.map((item, i) => (
|
{bestPriceItems.map((item, i) => (
|
||||||
@ -124,10 +174,11 @@ const BestPriceSection: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,39 +1,72 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useQuery } from '@apollo/client';
|
||||||
|
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const CategoryNavSection: React.FC = () => (
|
interface CategoryNavItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_CATEGORIES: CategoryNavItem[] = [
|
||||||
|
{ id: '1', name: 'Детали для ТО', image: '/images/catalog_item.png' },
|
||||||
|
{ id: '2', name: 'Шины', image: '/images/catalog_item2.png' },
|
||||||
|
{ id: '3', name: 'Диски', image: '/images/catalog_item3.png' },
|
||||||
|
{ id: '4', name: 'Масла и жидкости', image: '/images/catalog_item4.png' },
|
||||||
|
{ id: '5', name: 'Инструменты', image: '/images/catalog_item5.png' },
|
||||||
|
{ id: '6', name: 'Автохимия', image: '/images/catalog_item6.png' },
|
||||||
|
{ id: '7', name: 'Аксессуары', image: '/images/catalog_item7.png' },
|
||||||
|
{ id: '8', name: 'Электрика', image: '/images/catalog_item8.png' },
|
||||||
|
{ id: '9', name: 'АКБ', image: '/images/catalog_item9.png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CategoryNavSection: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data } = useQuery<{ partsIndexCategoriesWithGroups: CategoryNavItem[] }>(
|
||||||
|
GET_PARTSINDEX_CATEGORIES,
|
||||||
|
{
|
||||||
|
variables: { lang: 'ru' },
|
||||||
|
errorPolicy: 'all',
|
||||||
|
fetchPolicy: 'cache-first',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = (data?.partsIndexCategoriesWithGroups && data.partsIndexCategoriesWithGroups.length > 0)
|
||||||
|
? data.partsIndexCategoriesWithGroups.slice(0, 9)
|
||||||
|
: FALLBACK_CATEGORIES;
|
||||||
|
|
||||||
|
const handleCategoryClick = (category: CategoryNavItem) => {
|
||||||
|
router.push({
|
||||||
|
pathname: '/catalog',
|
||||||
|
query: {
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: encodeURIComponent(category.name)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<section className="catnav">
|
<section className="catnav">
|
||||||
<div className="w-layout-blockcontainer batd w-container">
|
<div className="w-layout-blockcontainer batd w-container">
|
||||||
<div className="w-layout-hflex flex-block-108-copy">
|
<div className="w-layout-hflex flex-block-108-copy">
|
||||||
<div className="ci1">
|
{categories.map((category, idx) => (
|
||||||
<div className="text-block-54-copy">Детали для ТО</div>
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className={`ci${idx + 1}`}
|
||||||
|
style={category.image ? { cursor: 'pointer', backgroundImage: `url('${category.image}')`, backgroundSize: 'cover', backgroundPosition: 'center' } : { cursor: 'pointer' }}
|
||||||
|
onClick={() => handleCategoryClick(category)}
|
||||||
|
>
|
||||||
|
<div className={idx === 0 ? 'text-block-54-copy' : 'text-block-54'} style={{ textAlign: 'center' }}>
|
||||||
|
{category.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="ci2">
|
|
||||||
<div className="text-block-54">Шины</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci3">
|
|
||||||
<div className="text-block-54">Диски</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci4">
|
|
||||||
<div className="text-block-54">Масла и жидкости</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci5">
|
|
||||||
<div className="text-block-54">Инструменты</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci6">
|
|
||||||
<div className="text-block-54">Автохимия</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci7">
|
|
||||||
<div className="text-block-54">Аксессуары</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci8">
|
|
||||||
<div className="text-block-54">Электрика</div>
|
|
||||||
</div>
|
|
||||||
<div className="ci9">
|
|
||||||
<div className="text-block-54">АКБ</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default CategoryNavSection;
|
export default CategoryNavSection;
|
@ -1,6 +1,23 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { useQuery } from '@apollo/client';
|
||||||
|
import { GET_HERO_BANNERS } from '@/lib/graphql';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface HeroBanner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
const HeroSlider = () => {
|
const HeroSlider = () => {
|
||||||
|
const { data, loading, error } = useQuery(GET_HERO_BANNERS, {
|
||||||
|
errorPolicy: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
|
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
|
||||||
if (window.Webflow.destroy) {
|
if (window.Webflow.destroy) {
|
||||||
@ -12,8 +29,16 @@ const HeroSlider = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Фильтруем только активные баннеры и сортируем их
|
||||||
|
const banners: HeroBanner[] = data?.heroBanners
|
||||||
|
?.filter((banner: HeroBanner) => banner.isActive)
|
||||||
|
?.slice()
|
||||||
|
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
|
||||||
|
|
||||||
|
// Если нет данных или происходит загрузка, показываем дефолтный баннер
|
||||||
|
if (loading || error || banners.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="section-5">
|
<section className="section-5" style={{ overflow: 'hidden' }}>
|
||||||
<div className="w-layout-blockcontainer container w-container">
|
<div className="w-layout-blockcontainer container w-container">
|
||||||
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease"
|
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease"
|
||||||
data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
|
data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
|
||||||
@ -21,92 +46,35 @@ const HeroSlider = () => {
|
|||||||
<div className="mask w-slider-mask">
|
<div className="mask w-slider-mask">
|
||||||
<div className="slide w-slide">
|
<div className="slide w-slide">
|
||||||
<div className="w-layout-vflex flex-block-100">
|
<div className="w-layout-vflex flex-block-100">
|
||||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
<div className="div-block-35">
|
||||||
|
<img src="/images/imgfb.png" loading="lazy"
|
||||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w"
|
||||||
className="image-21" /></div>
|
alt="Автозапчасти ProteK"
|
||||||
|
className="image-21" />
|
||||||
|
</div>
|
||||||
<div className="w-layout-vflex flex-block-99">
|
<div className="w-layout-vflex flex-block-99">
|
||||||
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
||||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
<div className="text-block-51">
|
||||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
Сотрудничаем только с проверенными поставщиками. Постоянно обновляем
|
||||||
|
ассортимент, чтобы предложить самые лучшие и актуальные детали.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-101">
|
<div className="w-layout-hflex flex-block-101">
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
<div className="w-layout-hflex flex-block-102">
|
||||||
className="image-20" />
|
<img src="/images/1.png" loading="lazy" alt="" className="image-20" />
|
||||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
<div className="w-layout-hflex flex-block-102">
|
||||||
className="image-20" />
|
<img src="/images/2.png" loading="lazy" alt="" className="image-20" />
|
||||||
<div className="text-block-52">Высокое качество продукции</div>
|
<div className="text-block-52">Высокое качество продукции</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
<div className="w-layout-hflex flex-block-102">
|
||||||
className="image-20" />
|
<img src="/images/3.png" loading="lazy" alt="" className="image-20" />
|
||||||
<div className="text-block-52">Выгодные цены</div>
|
<div className="text-block-52">Выгодные цены</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
<div className="w-layout-hflex flex-block-102">
|
||||||
className="image-20" />
|
<img src="/images/4.png" loading="lazy" alt="" className="image-20" />
|
||||||
<div className="text-block-52">Профессиональная консультация</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-slide">
|
|
||||||
<div className="w-layout-vflex flex-block-100">
|
|
||||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
|
||||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
|
||||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
|
||||||
className="image-21" /></div>
|
|
||||||
<div className="w-layout-vflex flex-block-99">
|
|
||||||
<h2 className="heading-17">УЗКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
|
||||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
|
||||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-101">
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Высокое качество продукции</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Выгодные цены</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Профессиональная консультация</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-slide">
|
|
||||||
<div className="w-layout-vflex flex-block-100">
|
|
||||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
|
||||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
|
||||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
|
||||||
className="image-21" /></div>
|
|
||||||
<div className="w-layout-vflex flex-block-99">
|
|
||||||
<h2 className="heading-17">ЛУЧШИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
|
||||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
|
||||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-101">
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Высокое качество продукции</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Выгодные цены</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
|
||||||
className="image-20" />
|
|
||||||
<div className="text-block-52">Профессиональная консультация</div>
|
<div className="text-block-52">Профессиональная консультация</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -128,6 +96,89 @@ const HeroSlider = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSlide = (banner: HeroBanner) => {
|
||||||
|
const slideContent = (
|
||||||
|
<div className="w-layout-vflex flex-block-100">
|
||||||
|
<div className="div-block-35">
|
||||||
|
<img
|
||||||
|
src={banner.imageUrl}
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||||
|
alt={banner.title}
|
||||||
|
className="image-21"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-layout-vflex flex-block-99">
|
||||||
|
<h2 className="heading-17">{banner.title}</h2>
|
||||||
|
{banner.subtitle && (
|
||||||
|
<div className="text-block-51">{banner.subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если есть ссылка, оборачиваем в Link
|
||||||
|
if (banner.linkUrl) {
|
||||||
|
return (
|
||||||
|
<Link href={banner.linkUrl} className="slide w-slide" style={{ cursor: 'pointer' }}>
|
||||||
|
{slideContent}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="slide w-slide">
|
||||||
|
{slideContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-5" style={{ overflow: 'hidden' }}>
|
||||||
|
<div className="w-layout-blockcontainer container w-container">
|
||||||
|
<div
|
||||||
|
data-delay="4000"
|
||||||
|
data-animation="slide"
|
||||||
|
className="slider w-slider"
|
||||||
|
data-autoplay="true"
|
||||||
|
data-easing="ease"
|
||||||
|
data-hide-arrows="false"
|
||||||
|
data-disable-swipe="false"
|
||||||
|
data-autoplay-limit="0"
|
||||||
|
data-nav-spacing="3"
|
||||||
|
data-duration="500"
|
||||||
|
data-infinite="true"
|
||||||
|
>
|
||||||
|
<div className="mask w-slider-mask">
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<React.Fragment key={banner.id}>
|
||||||
|
{renderSlide(banner)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Показываем стрелки и навигацию только если баннеров больше одного */}
|
||||||
|
{banners.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="left-arrow w-slider-arrow-left">
|
||||||
|
<div className="div-block-34">
|
||||||
|
<div className="icon-2 w-icon-slider-left"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="right-arrow w-slider-arrow-right">
|
||||||
|
<div className="div-block-34">
|
||||||
|
<div className="icon-2 w-icon-slider-right"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HeroSlider;
|
export default HeroSlider;
|
@ -84,16 +84,66 @@ const NewArrivalsSection: React.FC = () => {
|
|||||||
<h2 className="heading-4">Новое поступление</h2>
|
<h2 className="heading-4">Новое поступление</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel-row">
|
<div className="carousel-row">
|
||||||
|
{/* Стили для стрелок как в BestPriceSection и TopSalesSection */}
|
||||||
|
<style>{`
|
||||||
|
.carousel-arrow {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.carousel-arrow-left {}
|
||||||
|
.carousel-arrow-right {}
|
||||||
|
.carousel-arrow .arrow-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-circle,
|
||||||
|
.carousel-arrow:focus .arrow-circle {
|
||||||
|
background: #ec1c24;
|
||||||
|
}
|
||||||
|
.carousel-arrow .arrow-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
stroke: #222;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-svg,
|
||||||
|
.carousel-arrow:focus .arrow-svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.carousel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<button
|
<button
|
||||||
className="carousel-arrow carousel-arrow-left"
|
className="carousel-arrow carousel-arrow-left"
|
||||||
onClick={scrollLeft}
|
onClick={scrollLeft}
|
||||||
aria-label="Прокрутить влево"
|
aria-label="Прокрутить влево"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
||||||
@ -149,10 +199,11 @@ const NewArrivalsSection: React.FC = () => {
|
|||||||
aria-label="Прокрутить вправо"
|
aria-label="Прокрутить вправо"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -32,11 +32,61 @@ const NewsAndPromos = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel-row">
|
<div className="carousel-row">
|
||||||
|
{/* Стили для стрелок как в других секциях */}
|
||||||
|
<style>{`
|
||||||
|
.carousel-arrow {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.carousel-arrow-left {}
|
||||||
|
.carousel-arrow-right {}
|
||||||
|
.carousel-arrow .arrow-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-circle,
|
||||||
|
.carousel-arrow:focus .arrow-circle {
|
||||||
|
background: #ec1c24;
|
||||||
|
}
|
||||||
|
.carousel-arrow .arrow-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
stroke: #222;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-svg,
|
||||||
|
.carousel-arrow:focus .arrow-svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.carousel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-layout-hflex flex-block-6-copy-copy carousel-scroll" ref={scrollRef}>
|
<div className="w-layout-hflex flex-block-6-copy-copy carousel-scroll" ref={scrollRef}>
|
||||||
<NewsCard
|
<NewsCard
|
||||||
@ -69,10 +119,11 @@ const NewsAndPromos = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
248
src/components/index/ProductOfDayBanner.tsx
Normal file
248
src/components/index/ProductOfDayBanner.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useQuery } from '@apollo/client';
|
||||||
|
import { GET_HERO_BANNERS } from '@/lib/graphql';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface HeroBanner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавим CSS для стрелок
|
||||||
|
const arrowStyles = `
|
||||||
|
.pod-slider-arrow {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pod-slider-arrow-left { left: 12px; }
|
||||||
|
.pod-slider-arrow-right { right: 12px; }
|
||||||
|
.pod-slider-arrow .arrow-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.pod-slider-arrow:hover .arrow-circle,
|
||||||
|
.pod-slider-arrow:focus .arrow-circle {
|
||||||
|
background: #ec1c24;
|
||||||
|
}
|
||||||
|
.pod-slider-arrow .arrow-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
stroke: #222;
|
||||||
|
}
|
||||||
|
.pod-slider-arrow:hover .arrow-svg,
|
||||||
|
.pod-slider-arrow:focus .arrow-svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const slideStyles = `
|
||||||
|
.pod-slider-slide {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px) scale(0.98);
|
||||||
|
transition: opacity 0.5s cubic-bezier(.4,0,.2,1), transform 0.5s cubic-bezier(.4,0,.2,1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pod-slider-slide.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.pod-slider-slide.prev {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-40px) scale(0.98);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pod-slider-slide.next {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px) scale(0.98);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.mask.w-slider-mask { position: relative; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProductOfDayBanner: React.FC = () => {
|
||||||
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
|
const [showArrows, setShowArrows] = useState(false);
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { data } = useQuery(GET_HERO_BANNERS, { errorPolicy: 'all' });
|
||||||
|
|
||||||
|
const banners: HeroBanner[] = data?.heroBanners
|
||||||
|
?.filter((banner: HeroBanner) => banner.isActive)
|
||||||
|
?.slice()
|
||||||
|
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
|
||||||
|
|
||||||
|
const allBanners = banners.length > 0 ? banners : [{
|
||||||
|
id: 'default',
|
||||||
|
title: 'ДОСТАВИМ БЫСТРО!',
|
||||||
|
subtitle: 'Дополнительная скидка на товары с местного склада',
|
||||||
|
imageUrl: '/images/imgfb.png',
|
||||||
|
linkUrl: '',
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 0
|
||||||
|
}];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allBanners.length > 1) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentSlide(prev => (prev + 1) % allBanners.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [allBanners.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSlide >= allBanners.length) {
|
||||||
|
setCurrentSlide(0);
|
||||||
|
}
|
||||||
|
}, [allBanners.length, currentSlide]);
|
||||||
|
|
||||||
|
const handlePrevSlide = () => {
|
||||||
|
setCurrentSlide(prev => prev === 0 ? allBanners.length - 1 : prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextSlide = () => {
|
||||||
|
setCurrentSlide(prev => (prev + 1) % allBanners.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlideIndicator = (index: number) => {
|
||||||
|
setCurrentSlide(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Показывать стрелки при наведении на слайдер или стрелки
|
||||||
|
const handleMouseEnter = () => setShowArrows(true);
|
||||||
|
const handleMouseLeave = () => setShowArrows(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="slider w-slider"
|
||||||
|
ref={sliderRef}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
{/* Вставляем стили для стрелок */}
|
||||||
|
<style>{arrowStyles}{slideStyles}</style>
|
||||||
|
<div className="mask w-slider-mask">
|
||||||
|
{allBanners.map((banner, idx) => {
|
||||||
|
let slideClass = 'pod-slider-slide';
|
||||||
|
if (idx === currentSlide) slideClass += ' active';
|
||||||
|
else if (idx === (currentSlide === 0 ? allBanners.length - 1 : currentSlide - 1)) slideClass += ' prev';
|
||||||
|
else if (idx === (currentSlide + 1) % allBanners.length) slideClass += ' next';
|
||||||
|
const slideContent = (
|
||||||
|
<div
|
||||||
|
className="div-block-128"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${banner.imageUrl})`,
|
||||||
|
// backgroundSize: 'cover',
|
||||||
|
// backgroundPosition: 'center',
|
||||||
|
// backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Можно добавить текст поверх баннера, если нужно */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={slideClass + ' slide w-slide'}
|
||||||
|
key={banner.id}
|
||||||
|
// style={{ display: idx === currentSlide ? 'block' : 'none', position: 'relative' }}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
{banner.linkUrl ? (
|
||||||
|
<Link href={banner.linkUrl} style={{ display: 'block', width: '100%', height: '100%' }}>{slideContent}</Link>
|
||||||
|
) : slideContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* SVG-стрелки как в Webflow, поверх баннера, с hover-эффектом */}
|
||||||
|
<button
|
||||||
|
className="pod-slider-arrow pod-slider-arrow-left"
|
||||||
|
onClick={handlePrevSlide}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{
|
||||||
|
opacity: showArrows ? 1 : 0,
|
||||||
|
pointerEvents: showArrows ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Предыдущий баннер"
|
||||||
|
>
|
||||||
|
<span className="arrow-circle">
|
||||||
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pod-slider-arrow pod-slider-arrow-right"
|
||||||
|
onClick={handleNextSlide}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{
|
||||||
|
opacity: showArrows ? 1 : 0,
|
||||||
|
pointerEvents: showArrows ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Следующий баннер"
|
||||||
|
>
|
||||||
|
<span className="arrow-circle">
|
||||||
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round">
|
||||||
|
{allBanners.map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="w-slider-dot"
|
||||||
|
style={{
|
||||||
|
background: idx === currentSlide ? 'white' : 'rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
margin: 4,
|
||||||
|
display: 'inline-block',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => handleSlideIndicator(idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductOfDayBanner;
|
@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useQuery } from '@apollo/client';
|
import { useQuery } from '@apollo/client';
|
||||||
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
|
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import ProductOfDayBanner from './ProductOfDayBanner';
|
||||||
|
|
||||||
interface DailyProduct {
|
interface DailyProduct {
|
||||||
id: string;
|
id: string;
|
||||||
@ -31,7 +32,6 @@ const ProductOfDaySection: React.FC = () => {
|
|||||||
|
|
||||||
// Состояние для текущего слайда
|
// Состояние для текущего слайда
|
||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
const sliderRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
|
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
|
||||||
GET_DAILY_PRODUCTS,
|
GET_DAILY_PRODUCTS,
|
||||||
@ -110,10 +110,15 @@ const ProductOfDaySection: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Если нет ни одной картинки, возвращаем noimage.png
|
||||||
|
return {
|
||||||
|
url: '/images/noimage.png',
|
||||||
|
alt: product.name,
|
||||||
|
source: 'noimage'
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчики для слайдера
|
// Обработчики для навигации по товарам дня
|
||||||
const handlePrevSlide = (e: React.MouseEvent) => {
|
const handlePrevSlide = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -158,63 +163,7 @@ const ProductOfDaySection: React.FC = () => {
|
|||||||
<section className="main">
|
<section className="main">
|
||||||
<div className="w-layout-blockcontainer batd w-container">
|
<div className="w-layout-blockcontainer batd w-container">
|
||||||
<div className="w-layout-hflex flex-block-108">
|
<div className="w-layout-hflex flex-block-108">
|
||||||
<div
|
<ProductOfDayBanner />
|
||||||
ref={sliderRef}
|
|
||||||
className="slider w-slider"
|
|
||||||
>
|
|
||||||
<div className="mask w-slider-mask">
|
|
||||||
{activeProducts.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`slide w-slide ${index === currentSlide ? 'w--current' : ''}`}
|
|
||||||
style={{
|
|
||||||
display: index === currentSlide ? 'block' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="div-block-128"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Стрелки слайдера (показываем только если товаров больше 1) */}
|
|
||||||
{activeProducts.length > 1 && (
|
|
||||||
<>
|
|
||||||
<div className="left-arrow w-slider-arrow-left">
|
|
||||||
<div className="div-block-34">
|
|
||||||
<div className="code-embed-14 w-embed">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="right-arrow w-slider-arrow-right">
|
|
||||||
<div className="div-block-34 right">
|
|
||||||
<div className="code-embed-14 w-embed">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Индикаторы слайдов */}
|
|
||||||
{activeProducts.length > 1 && (
|
|
||||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round">
|
|
||||||
{activeProducts.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`w-slider-dot ${index === currentSlide ? 'w--current' : ''}`}
|
|
||||||
onClick={() => handleSlideIndicator(index)}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
style={{ cursor: 'pointer', zIndex: 10 }}
|
|
||||||
></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="div-block-129">
|
<div className="div-block-129">
|
||||||
<div className="w-layout-hflex flex-block-109">
|
<div className="w-layout-hflex flex-block-109">
|
||||||
@ -265,6 +214,11 @@ const ProductOfDaySection: React.FC = () => {
|
|||||||
Parts Index
|
Parts Index
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{productImage.source === 'noimage' && (
|
||||||
|
<div className="absolute bottom-0 right-0 bg-gray-400 text-white text-xs px-2 py-1 rounded-tl">
|
||||||
|
Нет изображения
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -143,11 +143,61 @@ const TopSalesSection: React.FC = () => {
|
|||||||
<h2 className="heading-4">Топ продаж</h2>
|
<h2 className="heading-4">Топ продаж</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel-row">
|
<div className="carousel-row">
|
||||||
|
{/* Стили для стрелок как в BestPriceSection */}
|
||||||
|
<style>{`
|
||||||
|
.carousel-arrow {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.carousel-arrow-left {}
|
||||||
|
.carousel-arrow-right {}
|
||||||
|
.carousel-arrow .arrow-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-circle,
|
||||||
|
.carousel-arrow:focus .arrow-circle {
|
||||||
|
background: #ec1c24;
|
||||||
|
}
|
||||||
|
.carousel-arrow .arrow-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
stroke: #222;
|
||||||
|
}
|
||||||
|
.carousel-arrow:hover .arrow-svg,
|
||||||
|
.carousel-arrow:focus .arrow-svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
.carousel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
||||||
{activeTopSalesProducts.map((item: TopSalesProductData) => {
|
{activeTopSalesProducts.map((item: TopSalesProductData) => {
|
||||||
@ -177,10 +227,11 @@ const TopSalesSection: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<span className="arrow-circle">
|
||||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -112,6 +112,19 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Состояния для отображения ошибок валидации
|
||||||
|
const [validationErrors, setValidationErrors] = React.useState({
|
||||||
|
inn: false,
|
||||||
|
shortName: false,
|
||||||
|
jurAddress: false,
|
||||||
|
form: false,
|
||||||
|
taxSystem: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция для очистки ошибки при изменении поля
|
||||||
|
const clearError = (field: keyof typeof validationErrors) => {
|
||||||
|
setValidationErrors(prev => ({ ...prev, [field]: false }));
|
||||||
|
};
|
||||||
const [createLegalEntity, { loading: createLoading }] = useMutation(CREATE_CLIENT_LEGAL_ENTITY, {
|
const [createLegalEntity, { loading: createLoading }] = useMutation(CREATE_CLIENT_LEGAL_ENTITY, {
|
||||||
onCompleted: () => {
|
onCompleted: () => {
|
||||||
console.log('Юридическое лицо создано');
|
console.log('Юридическое лицо создано');
|
||||||
@ -137,29 +150,27 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
const loading = createLoading || updateLoading;
|
const loading = createLoading || updateLoading;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Валидация
|
// Сброс предыдущих ошибок
|
||||||
if (!inn || inn.length < 10) {
|
setValidationErrors({
|
||||||
alert('Введите корректный ИНН');
|
inn: false,
|
||||||
return;
|
shortName: false,
|
||||||
}
|
jurAddress: false,
|
||||||
|
form: false,
|
||||||
|
taxSystem: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!shortName.trim()) {
|
// Валидация с установкой ошибок
|
||||||
alert('Введите краткое наименование');
|
const errors = {
|
||||||
return;
|
inn: !inn || inn.length < 10,
|
||||||
}
|
shortName: !shortName.trim(),
|
||||||
|
jurAddress: !jurAddress.trim(),
|
||||||
|
form: form === 'Выбрать',
|
||||||
|
taxSystem: taxSystem === 'Выбрать',
|
||||||
|
};
|
||||||
|
|
||||||
if (!jurAddress.trim()) {
|
// Если есть ошибки, устанавливаем их и прерываем выполнение
|
||||||
alert('Введите юридический адрес');
|
if (Object.values(errors).some(error => error)) {
|
||||||
return;
|
setValidationErrors(errors);
|
||||||
}
|
|
||||||
|
|
||||||
if (form === 'Выбрать') {
|
|
||||||
alert('Выберите форму организации');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (taxSystem === 'Выбрать') {
|
|
||||||
alert('Выберите систему налогообложения');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,13 +249,18 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<div className="flex flex-wrap gap-5 items-start w-full whitespace-nowrap max-md:max-w-full">
|
<div className="flex flex-wrap gap-5 items-start w-full whitespace-nowrap max-md:max-w-full">
|
||||||
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
||||||
<div className="text-gray-950">ИНН</div>
|
<div className="text-gray-950">ИНН</div>
|
||||||
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
|
<div className={`gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid min-h-[46px] max-md:px-5 ${
|
||||||
|
validationErrors.inn ? 'border-red-500' : 'border-stone-300'
|
||||||
|
}`}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ИНН"
|
placeholder="ИНН"
|
||||||
className="w-full bg-transparent outline-none text-gray-600"
|
className="w-full bg-transparent outline-none text-gray-600"
|
||||||
value={inn}
|
value={inn}
|
||||||
onChange={e => setInn(e.target.value)}
|
onChange={e => {
|
||||||
|
setInn(e.target.value);
|
||||||
|
clearError('inn');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -252,7 +268,9 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<div className="text-gray-950">Форма</div>
|
<div className="text-gray-950">Форма</div>
|
||||||
<div className="relative mt-1.5">
|
<div className="relative mt-1.5">
|
||||||
<div
|
<div
|
||||||
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
|
className={`flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none ${
|
||||||
|
validationErrors.form ? 'border-red-500' : 'border-stone-300'
|
||||||
|
}`}
|
||||||
onClick={() => setIsFormOpen((prev: boolean) => !prev)}
|
onClick={() => setIsFormOpen((prev: boolean) => !prev)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onBlur={() => setIsFormOpen(false)}
|
onBlur={() => setIsFormOpen(false)}
|
||||||
@ -266,7 +284,11 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<li
|
<li
|
||||||
key={option}
|
key={option}
|
||||||
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === form ? 'bg-blue-50 font-semibold' : ''}`}
|
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === form ? 'bg-blue-50 font-semibold' : ''}`}
|
||||||
onMouseDown={() => { setForm(option); setIsFormOpen(false); }}
|
onMouseDown={() => {
|
||||||
|
setForm(option);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
clearError('form');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</li>
|
</li>
|
||||||
@ -303,25 +325,35 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
|
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
|
||||||
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
||||||
<div className="text-gray-950">Юридический адрес</div>
|
<div className="text-gray-950">Юридический адрес</div>
|
||||||
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
|
<div className={`gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid min-h-[46px] text-neutral-500 max-md:px-5 ${
|
||||||
|
validationErrors.jurAddress ? 'border-red-500' : 'border-stone-300'
|
||||||
|
}`}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Юридический адрес"
|
placeholder="Юридический адрес"
|
||||||
className="w-full bg-transparent outline-none text-neutral-500"
|
className="w-full bg-transparent outline-none text-neutral-500"
|
||||||
value={jurAddress}
|
value={jurAddress}
|
||||||
onChange={e => setJurAddress(e.target.value)}
|
onChange={e => {
|
||||||
|
setJurAddress(e.target.value);
|
||||||
|
clearError('jurAddress');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
|
||||||
<div className="text-gray-950">Краткое наименование</div>
|
<div className="text-gray-950">Краткое наименование</div>
|
||||||
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
|
<div className={`gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid min-h-[46px] text-neutral-500 max-md:px-5 ${
|
||||||
|
validationErrors.shortName ? 'border-red-500' : 'border-stone-300'
|
||||||
|
}`}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Краткое наименование"
|
placeholder="Краткое наименование"
|
||||||
className="w-full bg-transparent outline-none text-neutral-500"
|
className="w-full bg-transparent outline-none text-neutral-500"
|
||||||
value={shortName}
|
value={shortName}
|
||||||
onChange={e => setShortName(e.target.value)}
|
onChange={e => {
|
||||||
|
setShortName(e.target.value);
|
||||||
|
clearError('shortName');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -355,7 +387,9 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<div className="text-gray-950">Система налогоблажения</div>
|
<div className="text-gray-950">Система налогоблажения</div>
|
||||||
<div className="relative mt-1.5">
|
<div className="relative mt-1.5">
|
||||||
<div
|
<div
|
||||||
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
|
className={`flex gap-10 justify-between items-center px-6 py-3.5 w-full whitespace-nowrap bg-white rounded border border-solid min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none ${
|
||||||
|
validationErrors.taxSystem ? 'border-red-500' : 'border-stone-300'
|
||||||
|
}`}
|
||||||
onClick={() => setIsTaxSystemOpen((prev: boolean) => !prev)}
|
onClick={() => setIsTaxSystemOpen((prev: boolean) => !prev)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onBlur={() => setIsTaxSystemOpen(false)}
|
onBlur={() => setIsTaxSystemOpen(false)}
|
||||||
@ -369,7 +403,11 @@ const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
|
|||||||
<li
|
<li
|
||||||
key={option}
|
key={option}
|
||||||
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === taxSystem ? 'bg-blue-50 font-semibold' : ''}`}
|
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === taxSystem ? 'bg-blue-50 font-semibold' : ''}`}
|
||||||
onMouseDown={() => { setTaxSystem(option); setIsTaxSystemOpen(false); }}
|
onMouseDown={() => {
|
||||||
|
setTaxSystem(option);
|
||||||
|
setIsTaxSystemOpen(false);
|
||||||
|
clearError('taxSystem');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</li>
|
</li>
|
||||||
|
@ -284,7 +284,7 @@ const ProfileHistoryMain = () => {
|
|||||||
|
|
||||||
if (loading && historyItems.length === 0) {
|
if (loading && historyItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center text-base min-h-[526px] h-full">
|
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px] h-full">
|
||||||
<div className="flex justify-center items-center h-40">
|
<div className="flex justify-center items-center h-40">
|
||||||
<div className="text-gray-500">Загрузка истории поиска...</div>
|
<div className="text-gray-500">Загрузка истории поиска...</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,7 +294,7 @@ const ProfileHistoryMain = () => {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center text-base min-h-[526px]">
|
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px]">
|
||||||
<div className="flex justify-center items-center h-40">
|
<div className="flex justify-center items-center h-40">
|
||||||
<div className="text-red-500">Ошибка загрузки истории поиска</div>
|
<div className="text-red-500">Ошибка загрузки истории поиска</div>
|
||||||
</div>
|
</div>
|
||||||
@ -303,7 +303,7 @@ const ProfileHistoryMain = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-[526px]">
|
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px]">
|
||||||
<div className="flex gap-5 items-center px-8 py-3 w-full leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
|
<div className="flex gap-5 items-center px-8 py-3 w-full leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
|
||||||
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full max-md:w-full">
|
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full max-md:w-full">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
@ -404,6 +404,7 @@ const KnotIn: React.FC<KnotInProps> = ({
|
|||||||
onClick={() => setIsImageModalOpen(false)}
|
onClick={() => setIsImageModalOpen(false)}
|
||||||
style={{ cursor: 'zoom-out' }}
|
style={{ cursor: 'zoom-out' }}
|
||||||
>
|
>
|
||||||
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={unitName || unitInfo?.name || "Изображение узла"}
|
alt={unitName || unitInfo?.name || "Изображение узла"}
|
||||||
@ -411,9 +412,11 @@ const KnotIn: React.FC<KnotInProps> = ({
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{ background: '#fff' }}
|
style={{ background: '#fff' }}
|
||||||
/>
|
/>
|
||||||
|
{/* Убираем интерактивные точки в модальном окне */}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsImageModalOpen(false)}
|
onClick={() => setIsImageModalOpen(false)}
|
||||||
className="absolute top-4 right-4 text-white text-3xl font-bold bg-black bg-opacity-40 rounded-full w-10 h-10 flex items-center justify-center"
|
className="absolute top-4 right-4 text-white text-3xl font-bold bg-black bg-opacity-40 rounded-full w-10 h-10 flex items-center justify-center hover:bg-black hover:bg-opacity-60 transition-colors"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
style={{ zIndex: 10000 }}
|
style={{ zIndex: 10000 }}
|
||||||
>
|
>
|
||||||
|
@ -36,7 +36,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||||
const [tooltipPart, setTooltipPart] = useState<any>(null);
|
const [tooltipPart, setTooltipPart] = useState<any>(null);
|
||||||
|
const [clickedPart, setClickedPart] = useState<string | number | null>(null);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Отладочные логи для проверки данных
|
// Отладочные логи для проверки данных
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -63,8 +65,31 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
|
|
||||||
// Обработчик клика по детали в списке
|
// Обработчик клика по детали в списке
|
||||||
const handlePartClick = (part: any) => {
|
const handlePartClick = (part: any) => {
|
||||||
if (part.codeonimage && onPartSelect) {
|
const codeOnImage = part.codeonimage || part.detailid;
|
||||||
onPartSelect(part.codeonimage);
|
if (codeOnImage && onPartSelect) {
|
||||||
|
onPartSelect(codeOnImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также подсвечиваем деталь на схеме при клике
|
||||||
|
if (codeOnImage && onPartHover) {
|
||||||
|
// Очищаем предыдущий таймер, если он есть
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем состояние кликнутой детали
|
||||||
|
setClickedPart(codeOnImage);
|
||||||
|
|
||||||
|
// Подсвечиваем на схеме
|
||||||
|
onPartHover(codeOnImage);
|
||||||
|
|
||||||
|
// Убираем подсветку через интервал
|
||||||
|
clickTimeoutRef.current = setTimeout(() => {
|
||||||
|
setClickedPart(null);
|
||||||
|
if (onPartHover) {
|
||||||
|
onPartHover(null);
|
||||||
|
}
|
||||||
|
}, 1500); // Подсветка будет видна 1.5 секунды
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,6 +175,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -213,12 +241,17 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
|
|
||||||
<div className="knot-parts">
|
<div className="knot-parts">
|
||||||
{parts.map((part, idx) => {
|
{parts.map((part, idx) => {
|
||||||
|
const codeOnImage = part.codeonimage || part.detailid;
|
||||||
const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && (
|
const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && (
|
||||||
(part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) ||
|
(part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) ||
|
||||||
(part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString())
|
(part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString())
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.toString());
|
const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.toString());
|
||||||
|
const isClicked = clickedPart !== null && (
|
||||||
|
(part.codeonimage && part.codeonimage.toString() === clickedPart.toString()) ||
|
||||||
|
(part.detailid && part.detailid.toString() === clickedPart.toString())
|
||||||
|
);
|
||||||
|
|
||||||
// Создаем уникальный ключ
|
// Создаем уникальный ключ
|
||||||
const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`;
|
const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`;
|
||||||
@ -226,9 +259,11 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-colors ${
|
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-all duration-300 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-green-100 border-green-500'
|
? 'bg-green-100 border-green-500'
|
||||||
|
: isClicked
|
||||||
|
? 'bg-red-100 border-red-400 shadow-md'
|
||||||
: isHighlighted
|
: isHighlighted
|
||||||
? 'bg-slate-200'
|
? 'bg-slate-200'
|
||||||
: 'bg-white border-gray-200 hover:border-gray-300'
|
: 'bg-white border-gray-200 hover:border-gray-300'
|
||||||
@ -240,13 +275,37 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="w-layout-hflex flex-block-116">
|
<div className="w-layout-hflex flex-block-116">
|
||||||
<div
|
<div
|
||||||
className={`nuberlist ${isSelected ? 'text-green-700 font-bold' : isHighlighted ? ' font-bold' : ''}`}
|
className={`nuberlist ${
|
||||||
|
isSelected
|
||||||
|
? 'text-green-700 font-bold'
|
||||||
|
: isClicked
|
||||||
|
? 'text-red-700 font-bold'
|
||||||
|
: isHighlighted
|
||||||
|
? 'font-bold'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{part.codeonimage || idx + 1}
|
{part.codeonimage || idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className={`oemnuber ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>{part.oem}</div>
|
<div className={`oemnuber ${
|
||||||
|
isSelected
|
||||||
|
? 'text-green-800 font-semibold'
|
||||||
|
: isClicked
|
||||||
|
? 'text-red-800 font-semibold'
|
||||||
|
: isHighlighted
|
||||||
|
? 'font-semibold'
|
||||||
|
: ''
|
||||||
|
}`}>{part.oem}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`partsname ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>
|
<div className={`partsname ${
|
||||||
|
isSelected
|
||||||
|
? 'text-green-800 font-semibold'
|
||||||
|
: isClicked
|
||||||
|
? 'text-red-800 font-semibold'
|
||||||
|
: isHighlighted
|
||||||
|
? 'font-semibold'
|
||||||
|
: ''
|
||||||
|
}`}>
|
||||||
{part.name}
|
{part.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-layout-hflex flex-block-117">
|
<div className="w-layout-hflex flex-block-117">
|
||||||
|
@ -19,6 +19,7 @@ interface VehicleAttributesTooltipProps {
|
|||||||
const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
||||||
show,
|
show,
|
||||||
position,
|
position,
|
||||||
|
vehicleName,
|
||||||
vehicleAttributes,
|
vehicleAttributes,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
@ -27,7 +28,7 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
|||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex overflow-hidden flex-col items-center px-8 py-8 bg-slate-50 shadow-[0px_0px_20px_rgba(0,0,0,0.15)] rounded-2xl w-[450px] min-h-[365px] max-w-full fixed z-[9999]"
|
className="flex overflow-hidden flex-col items-center px-8 py-8 bg-slate-50 shadow-[0px_0px_20px_rgba(0,0,0,0.15)] rounded-2xl w-[450px] max-w-full fixed z-[9999]"
|
||||||
style={{
|
style={{
|
||||||
left: `${position.x + 120}px`,
|
left: `${position.x + 120}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
@ -45,16 +46,33 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex relative flex-col w-full">
|
<div className="flex relative flex-col w-full">
|
||||||
{vehicleAttributes.map((attr, idx) => (
|
{/* Заголовок */}
|
||||||
<div key={idx} className="flex gap-5 items-center mt-2 w-full whitespace-nowrap first:mt-0">
|
{vehicleName && (
|
||||||
<div className="self-stretch my-auto text-gray-400 w-[150px] truncate">
|
<div className="font-semibold text-lg text-black mb-3 truncate">{vehicleName}</div>
|
||||||
|
)}
|
||||||
|
{/* Список характеристик или сообщение */}
|
||||||
|
{vehicleAttributes.length > 0 ? (
|
||||||
|
vehicleAttributes.map((attr, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="grid grid-cols-[150px_1fr] gap-x-5 items-start mt-2 w-full first:mt-0"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 break-words whitespace-normal text-left">
|
||||||
{attr.name}
|
{attr.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="self-stretch my-auto font-medium text-black truncate">
|
<div
|
||||||
|
className="font-medium text-black break-words whitespace-normal text-left justify-self-start"
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
>
|
||||||
{attr.value}
|
{attr.value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||||
|
<div className="text-gray-400 mb-2">Дополнительная информация недоступна</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -88,10 +88,9 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
|
|||||||
))}
|
))}
|
||||||
{total > 3 && shownCount < total && (
|
{total > 3 && shownCount < total && (
|
||||||
<div className="flex gap-2 mt-2 w-full">
|
<div className="flex gap-2 mt-2 w-full">
|
||||||
{shownCount + 3 < total && (
|
|
||||||
<button
|
<button
|
||||||
className="expand-btn"
|
className="expand-btn"
|
||||||
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: shownCount + 3 }))}
|
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: total }))}
|
||||||
style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }}
|
style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }}
|
||||||
>
|
>
|
||||||
Развернуть
|
Развернуть
|
||||||
@ -99,10 +98,9 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
|
|||||||
<path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="showall-btn"
|
className="showall-btn"
|
||||||
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: total }))}
|
onClick={() => handleUnitClick(unit)}
|
||||||
style={{ background: '#e9eef5', borderRadius: 8, color: '#222', padding: '6px 18px', border: 'none'}}
|
style={{ background: '#e9eef5', borderRadius: 8, color: '#222', padding: '6px 18px', border: 'none'}}
|
||||||
>
|
>
|
||||||
Показать все
|
Показать все
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { createContext, useContext, useReducer, useEffect, useState } from 'react'
|
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
import { CartState, CartContextType, CartItem, DeliveryInfo } from '@/types/cart'
|
import { CartState, CartContextType, CartItem, DeliveryInfo } from '@/types/cart'
|
||||||
|
import { ADD_TO_CART, REMOVE_FROM_CART, UPDATE_CART_ITEM_QUANTITY, CLEAR_CART, GET_CART } from '@/lib/graphql'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
// Начальное состояние корзины
|
// Начальное состояние корзины
|
||||||
const initialState: CartState = {
|
const initialState: CartState = {
|
||||||
@ -22,51 +25,53 @@ const initialState: CartState = {
|
|||||||
isLoading: false
|
isLoading: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Типы действий
|
// Создаем контекст
|
||||||
type CartAction =
|
const CartContext = createContext<CartContextType | undefined>(undefined)
|
||||||
| { 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 }
|
|
||||||
| { type: 'TOGGLE_FAVORITE'; payload: string }
|
|
||||||
| { type: 'UPDATE_COMMENT'; payload: { id: string; comment: string } }
|
|
||||||
| { type: 'UPDATE_ORDER_COMMENT'; payload: string }
|
|
||||||
| { type: 'SELECT_ALL' }
|
|
||||||
| { type: 'REMOVE_ALL' }
|
|
||||||
| { type: 'REMOVE_SELECTED' }
|
|
||||||
| { type: 'UPDATE_DELIVERY'; payload: Partial<DeliveryInfo> }
|
|
||||||
| { type: 'CLEAR_CART' }
|
|
||||||
| { type: 'LOAD_CART'; payload: CartItem[] }
|
|
||||||
| { type: 'LOAD_FULL_STATE'; payload: { items: CartItem[]; delivery: DeliveryInfo; orderComment: string } }
|
|
||||||
| { type: 'SET_LOADING'; payload: boolean }
|
|
||||||
| { type: 'SET_ERROR'; payload: string }
|
|
||||||
|
|
||||||
// Функция для генерации ID
|
|
||||||
const generateId = () => Math.random().toString(36).substr(2, 9)
|
|
||||||
|
|
||||||
// Утилитарная функция для парсинга количества в наличии
|
// Утилитарная функция для парсинга количества в наличии
|
||||||
const parseStock = (stockStr: string | number | undefined): number => {
|
const parseStock = (stockStr: string | number | undefined): number => {
|
||||||
if (typeof stockStr === 'number') return stockStr;
|
if (stockStr === undefined || stockStr === null) return 0
|
||||||
|
if (typeof stockStr === 'number') return stockStr
|
||||||
if (typeof stockStr === 'string') {
|
if (typeof stockStr === 'string') {
|
||||||
const match = stockStr.match(/\d+/);
|
// Извлекаем числа из строки типа "10 шт" или "В наличии: 5"
|
||||||
return match ? parseInt(match[0]) : 0;
|
const match = stockStr.match(/\d+/)
|
||||||
|
return match ? parseInt(match[0], 10) : 0
|
||||||
}
|
}
|
||||||
return 0;
|
return 0
|
||||||
};
|
}
|
||||||
|
|
||||||
// Функция для расчета итогов
|
// Функция для преобразования backend cart items в frontend format
|
||||||
const calculateSummary = (items: CartItem[], deliveryPrice: number) => {
|
const transformBackendItems = (backendItems: any[]): CartItem[] => {
|
||||||
const selectedItems = items.filter(item => item.selected)
|
return backendItems.map(item => ({
|
||||||
const totalItems = selectedItems.reduce((sum, item) => sum + item.quantity, 0)
|
id: item.id,
|
||||||
const totalPrice = selectedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
|
productId: item.productId,
|
||||||
const totalDiscount = selectedItems.reduce((sum, item) => {
|
offerKey: item.offerKey,
|
||||||
const discount = item.originalPrice ? (item.originalPrice - item.price) * item.quantity : 0
|
name: item.name,
|
||||||
return sum + discount
|
description: item.description,
|
||||||
}, 0)
|
brand: item.brand,
|
||||||
// Доставка включена в стоимость товаров, поэтому добавляем её только если есть товары
|
article: item.article,
|
||||||
const finalPrice = totalPrice + (totalPrice > 0 ? 0 : 0) // Доставка всегда включена в цену товаров
|
price: item.price,
|
||||||
|
currency: item.currency || 'RUB',
|
||||||
|
quantity: item.quantity,
|
||||||
|
stock: item.stock,
|
||||||
|
deliveryTime: item.deliveryTime,
|
||||||
|
warehouse: item.warehouse,
|
||||||
|
supplier: item.supplier,
|
||||||
|
isExternal: item.isExternal,
|
||||||
|
image: item.image,
|
||||||
|
selected: true,
|
||||||
|
favorite: false,
|
||||||
|
comment: ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для подсчета статистики корзины
|
||||||
|
const calculateSummary = (items: CartItem[]) => {
|
||||||
|
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
const totalPrice = items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
|
||||||
|
const totalDiscount = 0 // TODO: Implement discount logic
|
||||||
|
const deliveryPrice = 39
|
||||||
|
const finalPrice = totalPrice + deliveryPrice - totalDiscount
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalItems,
|
totalItems,
|
||||||
@ -77,373 +82,317 @@ const calculateSummary = (items: CartItem[], deliveryPrice: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Редьюсер корзины
|
// Провайдер контекста
|
||||||
const cartReducer = (state: CartState, action: CartAction): CartState => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'ADD_ITEM': {
|
|
||||||
const existingItemIndex = state.items.findIndex(
|
|
||||||
item =>
|
|
||||||
(item.productId && item.productId === action.payload.productId) ||
|
|
||||||
(item.offerKey && item.offerKey === action.payload.offerKey)
|
|
||||||
)
|
|
||||||
|
|
||||||
let newItems: CartItem[]
|
|
||||||
|
|
||||||
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: totalQuantity }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Добавляем новый товар
|
|
||||||
const newItem: CartItem = {
|
|
||||||
...action.payload,
|
|
||||||
id: generateId(),
|
|
||||||
selected: true,
|
|
||||||
favorite: false
|
|
||||||
}
|
|
||||||
newItems = [...state.items, newItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REMOVE_ITEM': {
|
|
||||||
const newItems = state.items.filter(item => item.id !== action.payload)
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'UPDATE_QUANTITY': {
|
|
||||||
const newItems = state.items.map(item =>
|
|
||||||
item.id === action.payload.id
|
|
||||||
? { ...item, quantity: Math.max(1, action.payload.quantity) }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'TOGGLE_SELECT': {
|
|
||||||
const newItems = state.items.map(item =>
|
|
||||||
item.id === action.payload
|
|
||||||
? { ...item, selected: !item.selected }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'TOGGLE_FAVORITE': {
|
|
||||||
const newItems = state.items.map(item =>
|
|
||||||
item.id === action.payload
|
|
||||||
? { ...item, favorite: !item.favorite }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'UPDATE_COMMENT': {
|
|
||||||
const newItems = state.items.map(item =>
|
|
||||||
item.id === action.payload.id
|
|
||||||
? { ...item, comment: action.payload.comment }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'UPDATE_ORDER_COMMENT': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
orderComment: action.payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'SELECT_ALL': {
|
|
||||||
const allSelected = state.items.every(item => item.selected)
|
|
||||||
const newItems = state.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
selected: !allSelected
|
|
||||||
}))
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REMOVE_ALL': {
|
|
||||||
const newSummary = calculateSummary([], state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: [],
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REMOVE_SELECTED': {
|
|
||||||
const newItems = state.items.filter(item => !item.selected)
|
|
||||||
const newSummary = calculateSummary(newItems, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: newItems,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'UPDATE_DELIVERY': {
|
|
||||||
const newDelivery = { ...state.delivery, ...action.payload }
|
|
||||||
const newSummary = calculateSummary(state.items, newDelivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
delivery: newDelivery,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'CLEAR_CART': {
|
|
||||||
const newSummary = calculateSummary([], state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: [],
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'LOAD_CART': {
|
|
||||||
const newSummary = calculateSummary(action.payload, state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: action.payload,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'LOAD_FULL_STATE': {
|
|
||||||
const newSummary = calculateSummary(action.payload.items, action.payload.delivery.price || state.delivery.price)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
items: action.payload.items,
|
|
||||||
delivery: action.payload.delivery,
|
|
||||||
orderComment: action.payload.orderComment,
|
|
||||||
summary: newSummary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'SET_LOADING': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isLoading: action.payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'SET_ERROR': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
error: action.payload,
|
|
||||||
isLoading: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создание контекста
|
|
||||||
const CartContext = createContext<CartContextType | undefined>(undefined)
|
|
||||||
|
|
||||||
// Провайдер корзины
|
|
||||||
export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [state, dispatch] = useReducer(cartReducer, initialState)
|
const [state, setState] = useState<CartState>(initialState)
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [error, setError] = useState<string>('')
|
||||||
|
|
||||||
// Загрузка корзины из localStorage при инициализации
|
// GraphQL operations
|
||||||
|
const { data: cartData, loading: cartLoading, refetch: refetchCart } = useQuery(GET_CART, {
|
||||||
|
errorPolicy: 'ignore' // Don't show errors for unauthenticated users
|
||||||
|
})
|
||||||
|
|
||||||
|
const [addToCartMutation] = useMutation(ADD_TO_CART)
|
||||||
|
const [removeFromCartMutation] = useMutation(REMOVE_FROM_CART)
|
||||||
|
const [updateQuantityMutation] = useMutation(UPDATE_CART_ITEM_QUANTITY)
|
||||||
|
const [clearCartMutation] = useMutation(CLEAR_CART)
|
||||||
|
|
||||||
|
// Load cart from backend when component mounts or cart data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (cartData?.getCart) {
|
||||||
|
const backendItems = transformBackendItems(cartData.getCart.items)
|
||||||
|
const summary = calculateSummary(backendItems)
|
||||||
|
|
||||||
console.log('🔄 Загружаем состояние корзины из localStorage...')
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
const savedCartState = localStorage.getItem('cartState')
|
items: backendItems,
|
||||||
if (savedCartState) {
|
summary,
|
||||||
try {
|
isLoading: false
|
||||||
const cartState = JSON.parse(savedCartState)
|
}))
|
||||||
console.log('✅ Найдено сохраненное состояние корзины:', cartState)
|
|
||||||
// Загружаем полное состояние корзины
|
|
||||||
dispatch({ type: 'LOAD_FULL_STATE', payload: cartState })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка загрузки корзины из localStorage:', error)
|
|
||||||
// Попытаемся загрузить старый формат (только товары)
|
|
||||||
const savedCart = localStorage.getItem('cart')
|
|
||||||
if (savedCart) {
|
|
||||||
try {
|
|
||||||
const cartItems = JSON.parse(savedCart)
|
|
||||||
console.log('✅ Найдены товары в старом формате:', cartItems)
|
|
||||||
dispatch({ type: 'LOAD_CART', payload: cartItems })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка загрузки старой корзины:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log('ℹ️ Сохраненное состояние корзины не найдено')
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: [],
|
||||||
|
summary: calculateSummary([]),
|
||||||
|
isLoading: false
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
}, [cartData])
|
||||||
|
|
||||||
setIsInitialized(true)
|
// Set loading state
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Сохранение полного состояния корзины в localStorage при изменении (только после инициализации)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized || typeof window === 'undefined') return
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: cartLoading
|
||||||
|
}))
|
||||||
|
}, [cartLoading])
|
||||||
|
|
||||||
const stateToSave = {
|
// GraphQL-based cart operations
|
||||||
items: state.items,
|
|
||||||
delivery: state.delivery,
|
|
||||||
orderComment: state.orderComment
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('💾 Сохраняем состояние корзины:', stateToSave)
|
|
||||||
localStorage.setItem('cartState', JSON.stringify(stateToSave))
|
|
||||||
// Сохраняем также старый формат для совместимости
|
|
||||||
localStorage.setItem('cart', JSON.stringify(state.items))
|
|
||||||
}, [state.items, state.delivery, state.orderComment, isInitialized])
|
|
||||||
|
|
||||||
// Функции для работы с корзиной
|
|
||||||
const addItem = async (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
|
const addItem = async (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
|
||||||
// Проверяем наличие товара на складе перед добавлением
|
try {
|
||||||
const existingItemIndex = state.items.findIndex(
|
setError('')
|
||||||
existingItem =>
|
setState(prev => ({ ...prev, isLoading: true }))
|
||||||
(existingItem.productId && existingItem.productId === item.productId) ||
|
|
||||||
(existingItem.offerKey && existingItem.offerKey === item.offerKey)
|
|
||||||
)
|
|
||||||
|
|
||||||
let totalQuantity = item.quantity;
|
console.log('🛒 Adding item to backend cart:', item)
|
||||||
if (existingItemIndex >= 0) {
|
|
||||||
const existingItem = state.items[existingItemIndex];
|
const { data } = await addToCartMutation({
|
||||||
totalQuantity = existingItem.quantity + item.quantity;
|
variables: {
|
||||||
|
input: {
|
||||||
|
productId: item.productId || null,
|
||||||
|
offerKey: item.offerKey || null,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
brand: item.brand,
|
||||||
|
article: item.article,
|
||||||
|
price: item.price,
|
||||||
|
currency: item.currency || 'RUB',
|
||||||
|
quantity: item.quantity,
|
||||||
|
stock: item.stock || null,
|
||||||
|
deliveryTime: item.deliveryTime || null,
|
||||||
|
warehouse: item.warehouse || null,
|
||||||
|
supplier: item.supplier || null,
|
||||||
|
isExternal: item.isExternal || false,
|
||||||
|
image: item.image || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data?.addToCart?.success) {
|
||||||
|
// Update local state with backend response
|
||||||
|
if (data.addToCart.cart) {
|
||||||
|
const backendItems = transformBackendItems(data.addToCart.cart.items)
|
||||||
|
const summary = calculateSummary(backendItems)
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: backendItems,
|
||||||
|
summary,
|
||||||
|
isLoading: false
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем наличие товара на складе
|
|
||||||
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 })
|
// Refetch to ensure data consistency
|
||||||
|
refetchCart()
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
} else {
|
||||||
|
const errorMessage = data?.addToCart?.error || 'Ошибка добавления товара'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
const removeItem = (id: string) => {
|
console.error('❌ Error adding item to cart:', error)
|
||||||
dispatch({ type: 'REMOVE_ITEM', payload: id })
|
const errorMessage = 'Ошибка добавления товара в корзину'
|
||||||
}
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
const updateQuantity = (id: string, quantity: number) => {
|
toast.error(errorMessage)
|
||||||
// Найдем товар для проверки наличия
|
return { success: false, error: errorMessage }
|
||||||
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 } })
|
const removeItem = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
setState(prev => ({ ...prev, isLoading: true }))
|
||||||
|
|
||||||
|
console.log('🗑️ Removing item from backend cart:', id)
|
||||||
|
|
||||||
|
const { data } = await removeFromCartMutation({
|
||||||
|
variables: { itemId: id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data?.removeFromCart?.success) {
|
||||||
|
// Update local state
|
||||||
|
if (data.removeFromCart.cart) {
|
||||||
|
const backendItems = transformBackendItems(data.removeFromCart.cart.items)
|
||||||
|
const summary = calculateSummary(backendItems)
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: backendItems,
|
||||||
|
summary,
|
||||||
|
isLoading: false
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success(data.removeFromCart.message || 'Товар удален из корзины')
|
||||||
|
refetchCart()
|
||||||
|
} else {
|
||||||
|
const errorMessage = data?.removeFromCart?.error || 'Ошибка удаления товара'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error removing item from cart:', error)
|
||||||
|
const errorMessage = 'Ошибка удаления товара из корзины'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuantity = async (id: string, quantity: number) => {
|
||||||
|
try {
|
||||||
|
if (quantity < 1) return
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setState(prev => ({ ...prev, isLoading: true }))
|
||||||
|
|
||||||
|
console.log('📝 Updating item quantity in backend cart:', id, quantity)
|
||||||
|
|
||||||
|
const { data } = await updateQuantityMutation({
|
||||||
|
variables: { itemId: id, quantity }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data?.updateCartItemQuantity?.success) {
|
||||||
|
// Update local state
|
||||||
|
if (data.updateCartItemQuantity.cart) {
|
||||||
|
const backendItems = transformBackendItems(data.updateCartItemQuantity.cart.items)
|
||||||
|
const summary = calculateSummary(backendItems)
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: backendItems,
|
||||||
|
summary,
|
||||||
|
isLoading: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(data.updateCartItemQuantity.message || 'Количество обновлено')
|
||||||
|
refetchCart()
|
||||||
|
} else {
|
||||||
|
const errorMessage = data?.updateCartItemQuantity?.error || 'Ошибка обновления количества'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error updating item quantity:', error)
|
||||||
|
const errorMessage = 'Ошибка обновления количества товара'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCart = async () => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
setState(prev => ({ ...prev, isLoading: true }))
|
||||||
|
|
||||||
|
console.log('🧹 Clearing backend cart')
|
||||||
|
|
||||||
|
const { data } = await clearCartMutation()
|
||||||
|
|
||||||
|
if (data?.clearCart?.success) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: [],
|
||||||
|
summary: calculateSummary([]),
|
||||||
|
isLoading: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
toast.success(data.clearCart.message || 'Корзина очищена')
|
||||||
|
refetchCart()
|
||||||
|
} else {
|
||||||
|
const errorMessage = data?.clearCart?.error || 'Ошибка очистки корзины'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error clearing cart:', error)
|
||||||
|
const errorMessage = 'Ошибка очистки корзины'
|
||||||
|
setError(errorMessage)
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }))
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-only operations (not synced with backend)
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
dispatch({ type: 'TOGGLE_SELECT', payload: id })
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map(item =>
|
||||||
|
item.id === id ? { ...item, selected: !item.selected } : item
|
||||||
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFavorite = (id: string) => {
|
const toggleFavorite = (id: string) => {
|
||||||
dispatch({ type: 'TOGGLE_FAVORITE', payload: id })
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map(item =>
|
||||||
|
item.id === id ? { ...item, favorite: !item.favorite } : item
|
||||||
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateComment = (id: string, comment: string) => {
|
const updateComment = (id: string, comment: string) => {
|
||||||
dispatch({ type: 'UPDATE_COMMENT', payload: { id, comment } })
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map(item =>
|
||||||
|
item.id === id ? { ...item, comment } : item
|
||||||
|
)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateOrderComment = (comment: string) => {
|
const updateOrderComment = (comment: string) => {
|
||||||
dispatch({ type: 'UPDATE_ORDER_COMMENT', payload: comment })
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
orderComment: comment
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
dispatch({ type: 'SELECT_ALL' })
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map(item => ({ ...item, selected: true }))
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeAll = () => {
|
const removeAll = () => {
|
||||||
dispatch({ type: 'REMOVE_ALL' })
|
clearCart()
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSelected = () => {
|
const removeSelected = async () => {
|
||||||
dispatch({ type: 'REMOVE_SELECTED' })
|
const selectedItems = state.items.filter(item => item.selected)
|
||||||
|
for (const item of selectedItems) {
|
||||||
|
await removeItem(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDelivery = (delivery: Partial<DeliveryInfo>) => {
|
const updateDelivery = (delivery: Partial<DeliveryInfo>) => {
|
||||||
dispatch({ type: 'UPDATE_DELIVERY', payload: delivery })
|
setState(prev => ({
|
||||||
}
|
...prev,
|
||||||
|
delivery: { ...prev.delivery, ...delivery }
|
||||||
const clearCart = () => {
|
}))
|
||||||
dispatch({ type: 'CLEAR_CART' })
|
|
||||||
// Очищаем localStorage при очистке корзины
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('cartState')
|
|
||||||
localStorage.removeItem('cart')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
dispatch({ type: 'SET_ERROR', payload: '' })
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item is in cart (using backend data)
|
||||||
|
const isInCart = (productId?: string, offerKey?: string, article?: string, brand?: string): boolean => {
|
||||||
|
return state.items.some(item => {
|
||||||
|
if (productId && item.productId === productId) return true
|
||||||
|
if (offerKey && item.offerKey === offerKey) return true
|
||||||
|
if (article && brand && item.article === article && item.brand === brand) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextValue: CartContextType = {
|
const contextValue: CartContextType = {
|
||||||
state,
|
state: {
|
||||||
|
...state,
|
||||||
|
error
|
||||||
|
},
|
||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateQuantity,
|
updateQuantity,
|
||||||
@ -456,7 +405,8 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
removeSelected,
|
removeSelected,
|
||||||
updateDelivery,
|
updateDelivery,
|
||||||
clearCart,
|
clearCart,
|
||||||
clearError
|
clearError,
|
||||||
|
isInCart
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -466,7 +416,6 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Хук для использования контекста корзины
|
// Хук для использования контекста корзины
|
||||||
export const useCart = (): CartContextType => {
|
export const useCart = (): CartContextType => {
|
||||||
const context = useContext(CartContext)
|
const context = useContext(CartContext)
|
||||||
|
@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
|
|||||||
import { useMutation, useQuery } from '@apollo/client'
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
|
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
|
||||||
import DeleteCartIcon from '@/components/DeleteCartIcon'
|
import CloseIcon from '@/components/CloseIcon'
|
||||||
|
|
||||||
// Типы
|
// Типы
|
||||||
export interface FavoriteItem {
|
export interface FavoriteItem {
|
||||||
@ -135,7 +135,7 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
|
|||||||
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
|
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
|
||||||
onCompleted: () => {
|
onCompleted: () => {
|
||||||
toast('Товар удален из избранного', {
|
toast('Товар удален из избранного', {
|
||||||
icon: <DeleteCartIcon size={20} color="#ec1c24" />,
|
icon: <CloseIcon size={20} color="#fff" />,
|
||||||
style: {
|
style: {
|
||||||
background: '#6b7280', // Серый фон
|
background: '#6b7280', // Серый фон
|
||||||
color: '#fff', // Белый текст
|
color: '#fff', // Белый текст
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useLazyQuery } from '@apollo/client';
|
import { useLazyQuery } from '@apollo/client';
|
||||||
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
|
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
|
||||||
|
import { useCart } from '@/contexts/CartContext';
|
||||||
|
|
||||||
interface ProductOffer {
|
interface ProductOffer {
|
||||||
offerKey: string;
|
offerKey: string;
|
||||||
@ -15,6 +16,7 @@ interface ProductOffer {
|
|||||||
warehouse: string;
|
warehouse: string;
|
||||||
supplier: string;
|
supplier: string;
|
||||||
canPurchase: boolean;
|
canPurchase: boolean;
|
||||||
|
isInCart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductPriceData {
|
interface ProductPriceData {
|
||||||
@ -25,35 +27,60 @@ interface ProductPriceData {
|
|||||||
externalOffers: ProductOffer[];
|
externalOffers: ProductOffer[];
|
||||||
analogs: number;
|
analogs: number;
|
||||||
hasInternalStock: boolean;
|
hasInternalStock: boolean;
|
||||||
|
isInCart: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CartItemInput {
|
||||||
|
productId?: string;
|
||||||
|
offerKey?: string;
|
||||||
|
article: string;
|
||||||
|
brand: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductPriceVariables {
|
interface ProductPriceVariables {
|
||||||
articleNumber: string;
|
articleNumber: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
|
cartItems?: CartItemInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProductPrices = (products: Array<{ code: string; brand: string; id: string }>) => {
|
export const useProductPrices = () => {
|
||||||
const [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
|
const [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
|
||||||
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set());
|
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set());
|
||||||
|
const [loadedPrices, setLoadedPrices] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { state: cartState } = useCart();
|
||||||
const [searchOffers] = useLazyQuery<ProductPriceData, ProductPriceVariables>(SEARCH_PRODUCT_OFFERS);
|
const [searchOffers] = useLazyQuery<ProductPriceData, ProductPriceVariables>(SEARCH_PRODUCT_OFFERS);
|
||||||
|
|
||||||
const loadPrice = async (product: { code: string; brand: string; id: string }) => {
|
const loadPrice = useCallback(async (product: { code: string; brand: string; id: string }) => {
|
||||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||||
|
|
||||||
if (pricesMap.has(key) || loadingPrices.has(key)) {
|
// Если уже загружено или загружается - не делаем повторный запрос
|
||||||
return; // Уже загружено или загружается
|
if (loadedPrices.has(key) || loadingPrices.has(key)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('💰 Загружаем цену для:', product.code, product.brand);
|
console.log('💰 Загружаем цену для:', product.code, product.brand);
|
||||||
setLoadingPrices(prev => new Set([...prev, key]));
|
setLoadingPrices(prev => new Set([...prev, key]));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Преобразуем товары корзины в формат для запроса
|
||||||
|
const cartItems: CartItemInput[] = cartState.items
|
||||||
|
.filter(item => item.article && item.brand) // Фильтруем товары с обязательными полями
|
||||||
|
.map(item => ({
|
||||||
|
productId: item.productId,
|
||||||
|
offerKey: item.offerKey,
|
||||||
|
article: item.article!,
|
||||||
|
brand: item.brand!,
|
||||||
|
quantity: item.quantity
|
||||||
|
}));
|
||||||
|
|
||||||
const result = await searchOffers({
|
const result = await searchOffers({
|
||||||
variables: {
|
variables: {
|
||||||
articleNumber: product.code,
|
articleNumber: product.code,
|
||||||
brand: product.brand
|
brand: product.brand,
|
||||||
|
cartItems
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,35 +114,31 @@ export const useProductPrices = (products: Array<{ code: string; brand: string;
|
|||||||
newSet.delete(key);
|
newSet.delete(key);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
setLoadedPrices(prev => new Set([...prev, key]));
|
||||||
}
|
}
|
||||||
};
|
}, [searchOffers, loadedPrices, loadingPrices]);
|
||||||
|
|
||||||
useEffect(() => {
|
const getPrice = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||||
// Загружаем цены для всех товаров с небольшой задержкой между запросами
|
|
||||||
products.forEach((product, index) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
loadPrice(product);
|
|
||||||
}, index * 100); // Задержка 100мс между запросами
|
|
||||||
});
|
|
||||||
}, [products]);
|
|
||||||
|
|
||||||
const getPrice = (product: { code: string; brand: string; id: string }) => {
|
|
||||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||||
return pricesMap.get(key);
|
return pricesMap.get(key);
|
||||||
};
|
}, [pricesMap]);
|
||||||
|
|
||||||
const isLoadingPrice = (product: { code: string; brand: string; id: string }) => {
|
const isLoadingPrice = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||||
return loadingPrices.has(key);
|
return loadingPrices.has(key);
|
||||||
};
|
}, [loadingPrices]);
|
||||||
|
|
||||||
const loadPriceOnDemand = (product: { code: string; brand: string; id: string }) => {
|
const ensurePriceLoaded = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||||
|
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||||
|
if (!loadedPrices.has(key) && !loadingPrices.has(key)) {
|
||||||
loadPrice(product);
|
loadPrice(product);
|
||||||
};
|
}
|
||||||
|
}, [loadPrice, loadedPrices, loadingPrices]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPrice,
|
getPrice,
|
||||||
isLoadingPrice,
|
isLoadingPrice,
|
||||||
loadPriceOnDemand
|
loadPrice,
|
||||||
|
ensurePriceLoaded
|
||||||
};
|
};
|
||||||
};
|
};
|
@ -20,16 +20,25 @@ const authLink = setContext((_, { headers }) => {
|
|||||||
const user = JSON.parse(userData);
|
const user = JSON.parse(userData);
|
||||||
// Создаем токен в формате, который ожидает CMS
|
// Создаем токен в формате, который ожидает CMS
|
||||||
token = `client_${user.id}`;
|
token = `client_${user.id}`;
|
||||||
console.log('Apollo Client: создан токен:', token);
|
console.log('Apollo Client: создан токен для авторизованного пользователя:', token);
|
||||||
console.log('Apollo Client: user data:', user);
|
|
||||||
console.log('Apollo Client: заголовки:', { authorization: `Bearer ${token}` });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Apollo Client: ошибка парсинга userData:', error);
|
console.error('Apollo Client: ошибка парсинга userData:', error);
|
||||||
localStorage.removeItem('userData');
|
localStorage.removeItem('userData');
|
||||||
localStorage.removeItem('authToken');
|
localStorage.removeItem('authToken');
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
console.log('Apollo Client: userData не найден в localStorage');
|
|
||||||
|
// Если нет авторизованного пользователя, создаем анонимную сессию для корзины
|
||||||
|
if (!token) {
|
||||||
|
let sessionId = localStorage.getItem('anonymousSessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
// Генерируем уникальный ID сессии
|
||||||
|
sessionId = 'anon_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
|
||||||
|
localStorage.setItem('anonymousSessionId', sessionId);
|
||||||
|
console.log('Apollo Client: создана новая анонимная сессия:', sessionId);
|
||||||
|
}
|
||||||
|
token = `client_${sessionId}`;
|
||||||
|
console.log('Apollo Client: используется анонимная сессия:', token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,20 @@ export const GET_TOP_SALES_PRODUCTS = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_HERO_BANNERS = gql`
|
||||||
|
query GetHeroBanners {
|
||||||
|
heroBanners {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
subtitle
|
||||||
|
imageUrl
|
||||||
|
linkUrl
|
||||||
|
isActive
|
||||||
|
sortOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const CHECK_CLIENT_BY_PHONE = gql`
|
export const CHECK_CLIENT_BY_PHONE = gql`
|
||||||
mutation CheckClientByPhone($phone: String!) {
|
mutation CheckClientByPhone($phone: String!) {
|
||||||
checkClientByPhone(phone: $phone) {
|
checkClientByPhone(phone: $phone) {
|
||||||
@ -1111,14 +1125,25 @@ export const GET_LAXIMO_UNIT_IMAGE_MAP = gql`
|
|||||||
`
|
`
|
||||||
|
|
||||||
export const SEARCH_PRODUCT_OFFERS = gql`
|
export const SEARCH_PRODUCT_OFFERS = gql`
|
||||||
query SearchProductOffers($articleNumber: String!, $brand: String!) {
|
query SearchProductOffers($articleNumber: String!, $brand: String!, $cartItems: [CartItemInput!]) {
|
||||||
searchProductOffers(articleNumber: $articleNumber, brand: $brand) {
|
searchProductOffers(articleNumber: $articleNumber, brand: $brand, cartItems: $cartItems) {
|
||||||
articleNumber
|
articleNumber
|
||||||
brand
|
brand
|
||||||
name
|
name
|
||||||
description
|
description
|
||||||
hasInternalStock
|
hasInternalStock
|
||||||
totalOffers
|
totalOffers
|
||||||
|
isInCart
|
||||||
|
stockCalculation {
|
||||||
|
totalInternalStock
|
||||||
|
totalExternalStock
|
||||||
|
availableInternalOffers
|
||||||
|
availableExternalOffers
|
||||||
|
hasInternalStock
|
||||||
|
hasExternalStock
|
||||||
|
totalStock
|
||||||
|
hasAnyStock
|
||||||
|
}
|
||||||
images {
|
images {
|
||||||
id
|
id
|
||||||
url
|
url
|
||||||
@ -1154,6 +1179,7 @@ export const SEARCH_PRODUCT_OFFERS = gql`
|
|||||||
available
|
available
|
||||||
rating
|
rating
|
||||||
supplier
|
supplier
|
||||||
|
isInCart
|
||||||
}
|
}
|
||||||
externalOffers {
|
externalOffers {
|
||||||
offerKey
|
offerKey
|
||||||
@ -1171,6 +1197,7 @@ export const SEARCH_PRODUCT_OFFERS = gql`
|
|||||||
weight
|
weight
|
||||||
volume
|
volume
|
||||||
canPurchase
|
canPurchase
|
||||||
|
isInCart
|
||||||
}
|
}
|
||||||
analogs {
|
analogs {
|
||||||
brand
|
brand
|
||||||
@ -1178,6 +1205,16 @@ export const SEARCH_PRODUCT_OFFERS = gql`
|
|||||||
name
|
name
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
|
stockCalculation {
|
||||||
|
totalInternalStock
|
||||||
|
totalExternalStock
|
||||||
|
availableInternalOffers
|
||||||
|
availableExternalOffers
|
||||||
|
hasInternalStock
|
||||||
|
hasExternalStock
|
||||||
|
totalStock
|
||||||
|
hasAnyStock
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@ -1724,4 +1761,164 @@ export const GET_NEW_ARRIVALS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
|
// Cart mutations and queries
|
||||||
|
export const GET_CART = gql`
|
||||||
|
query GetCart {
|
||||||
|
getCart {
|
||||||
|
id
|
||||||
|
clientId
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
offerKey
|
||||||
|
name
|
||||||
|
description
|
||||||
|
brand
|
||||||
|
article
|
||||||
|
price
|
||||||
|
currency
|
||||||
|
quantity
|
||||||
|
stock
|
||||||
|
deliveryTime
|
||||||
|
warehouse
|
||||||
|
supplier
|
||||||
|
isExternal
|
||||||
|
image
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ADD_TO_CART = gql`
|
||||||
|
mutation AddToCart($input: AddToCartInput!) {
|
||||||
|
addToCart(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
error
|
||||||
|
cart {
|
||||||
|
id
|
||||||
|
clientId
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
offerKey
|
||||||
|
name
|
||||||
|
description
|
||||||
|
brand
|
||||||
|
article
|
||||||
|
price
|
||||||
|
currency
|
||||||
|
quantity
|
||||||
|
stock
|
||||||
|
deliveryTime
|
||||||
|
warehouse
|
||||||
|
supplier
|
||||||
|
isExternal
|
||||||
|
image
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REMOVE_FROM_CART = gql`
|
||||||
|
mutation RemoveFromCart($itemId: ID!) {
|
||||||
|
removeFromCart(itemId: $itemId) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
error
|
||||||
|
cart {
|
||||||
|
id
|
||||||
|
clientId
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
offerKey
|
||||||
|
name
|
||||||
|
description
|
||||||
|
brand
|
||||||
|
article
|
||||||
|
price
|
||||||
|
currency
|
||||||
|
quantity
|
||||||
|
stock
|
||||||
|
deliveryTime
|
||||||
|
warehouse
|
||||||
|
supplier
|
||||||
|
isExternal
|
||||||
|
image
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CART_ITEM_QUANTITY = gql`
|
||||||
|
mutation UpdateCartItemQuantity($itemId: ID!, $quantity: Int!) {
|
||||||
|
updateCartItemQuantity(itemId: $itemId, quantity: $quantity) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
error
|
||||||
|
cart {
|
||||||
|
id
|
||||||
|
clientId
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
offerKey
|
||||||
|
name
|
||||||
|
description
|
||||||
|
brand
|
||||||
|
article
|
||||||
|
price
|
||||||
|
currency
|
||||||
|
quantity
|
||||||
|
stock
|
||||||
|
deliveryTime
|
||||||
|
warehouse
|
||||||
|
supplier
|
||||||
|
isExternal
|
||||||
|
image
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_CART = gql`
|
||||||
|
mutation ClearCart {
|
||||||
|
clearCart {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
error
|
||||||
|
cart {
|
||||||
|
id
|
||||||
|
clientId
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
brand
|
||||||
|
article
|
||||||
|
quantity
|
||||||
|
price
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
@ -24,6 +24,20 @@ export const GET_PARTS_SEARCH_HISTORY = gql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Запрос для получения последних поисковых запросов для автодополнения
|
||||||
|
export const GET_RECENT_SEARCH_QUERIES = gql`
|
||||||
|
query GetRecentSearchQueries($limit: Int = 5) {
|
||||||
|
partsSearchHistory(limit: $limit, offset: 0) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
searchQuery
|
||||||
|
searchType
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const DELETE_SEARCH_HISTORY_ITEM = gql`
|
export const DELETE_SEARCH_HISTORY_ITEM = gql`
|
||||||
mutation DeletePartsSearchHistoryItem($id: ID!) {
|
mutation DeletePartsSearchHistoryItem($id: ID!) {
|
||||||
deletePartsSearchHistoryItem(id: $id)
|
deletePartsSearchHistoryItem(id: $id)
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
import { PartsIndexCatalogsResponse, PartsIndexGroup, PartsIndexEntityInfoResponse } from '@/types/partsindex';
|
import { PartsIndexCatalogsResponse, PartsIndexGroup, PartsIndexEntityInfoResponse } from '@/types/partsindex';
|
||||||
|
|
||||||
const PARTS_INDEX_API_BASE = 'https://api.parts-index.com';
|
const PARTS_INDEX_API_BASE = process.env.PARTSAPI_URL || 'https://api.parts-index.com';
|
||||||
const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
|
const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
|
||||||
|
|
||||||
|
// Debug logging for development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('🔍 PartsIndex API Base URL:', PARTS_INDEX_API_BASE);
|
||||||
|
console.log('🔍 Environment variable NEXT_PUBLIC_PARTSAPI_URL:', process.env.NEXT_PUBLIC_PARTSAPI_URL);
|
||||||
|
}
|
||||||
|
|
||||||
class PartsIndexService {
|
class PartsIndexService {
|
||||||
/**
|
/**
|
||||||
* Получить список каталогов
|
* Получить список каталогов
|
||||||
|
@ -93,3 +93,13 @@ export const memoize = <T extends (...args: any[]) => any>(
|
|||||||
export const clearMemoCache = () => {
|
export const clearMemoCache = () => {
|
||||||
memoCache.clear();
|
memoCache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Проверка, является ли строка датой доставки
|
||||||
|
export const isDeliveryDate = (dateString: string): boolean => {
|
||||||
|
const months = [
|
||||||
|
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||||
|
];
|
||||||
|
|
||||||
|
return months.some(month => dateString.includes(month));
|
||||||
|
};
|
@ -52,7 +52,7 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</Layout>
|
</Layout>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-center"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
style: {
|
style: {
|
||||||
@ -72,12 +72,19 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
|
style: {
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
iconTheme: {
|
iconTheme: {
|
||||||
primary: '#ef4444',
|
primary: '#ef4444',
|
||||||
secondary: '#fff',
|
secondary: '#fff',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
containerStyle={{
|
||||||
|
top: '80px', // Отступ для всего контейнера toast'ов
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Script src="/js/webflow.js" strategy="beforeInteractive" />
|
<Script src="/js/webflow.js" strategy="beforeInteractive" />
|
||||||
<Script
|
<Script
|
||||||
|
@ -38,7 +38,8 @@ const mockData = Array(12).fill({
|
|||||||
brand: "Borsehung",
|
brand: "Borsehung",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 50; // Уменьшено для быстрой загрузки и лучшего UX
|
||||||
|
const PARTSINDEX_PAGE_SIZE = 25; // Синхронизировано для оптимальной скорости
|
||||||
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
|
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
|
||||||
|
|
||||||
export default function Catalog() {
|
export default function Catalog() {
|
||||||
@ -55,6 +56,36 @@ export default function Catalog() {
|
|||||||
const [showSortMobile, setShowSortMobile] = useState(false);
|
const [showSortMobile, setShowSortMobile] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({});
|
const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({});
|
||||||
|
|
||||||
|
// Инициализация фильтров из URL при загрузке
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady) {
|
||||||
|
const urlFilters: {[key: string]: string[]} = {};
|
||||||
|
const urlSearchQuery = router.query.q as string || '';
|
||||||
|
|
||||||
|
// Восстанавливаем фильтры из URL
|
||||||
|
Object.keys(router.query).forEach(key => {
|
||||||
|
if (key.startsWith('filter_')) {
|
||||||
|
const filterName = key.replace('filter_', '');
|
||||||
|
const filterValue = router.query[key];
|
||||||
|
if (typeof filterValue === 'string') {
|
||||||
|
urlFilters[filterName] = [filterValue];
|
||||||
|
} else if (Array.isArray(filterValue)) {
|
||||||
|
urlFilters[filterName] = filterValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔗 Восстанавливаем фильтры из URL:', { urlFilters, urlSearchQuery });
|
||||||
|
|
||||||
|
if (Object.keys(urlFilters).length > 0) {
|
||||||
|
setSelectedFilters(urlFilters);
|
||||||
|
}
|
||||||
|
if (urlSearchQuery) {
|
||||||
|
setSearchQuery(urlSearchQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [router.isReady]);
|
||||||
const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]);
|
const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]);
|
||||||
const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]);
|
const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -73,6 +104,14 @@ export default function Catalog() {
|
|||||||
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
|
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
|
||||||
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц
|
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц
|
||||||
|
|
||||||
|
// Новые состояния для логики автоподгрузки PartsIndex
|
||||||
|
const [accumulatedEntities, setAccumulatedEntities] = useState<PartsIndexEntity[]>([]); // Все накопленные товары
|
||||||
|
const [entitiesWithOffers, setEntitiesWithOffers] = useState<PartsIndexEntity[]>([]); // Товары с предложениями
|
||||||
|
const [isAutoLoading, setIsAutoLoading] = useState(false); // Автоматическая подгрузка в процессе
|
||||||
|
const [currentUserPage, setCurrentUserPage] = useState(1); // Текущая пользовательская страница
|
||||||
|
const [entitiesCache, setEntitiesCache] = useState<Map<number, PartsIndexEntity[]>>(new Map()); // Кэш страниц
|
||||||
|
const [isFilterChanging, setIsFilterChanging] = useState(false); // Флаг изменения фильтров
|
||||||
|
|
||||||
// Карта видимости товаров по индексу
|
// Карта видимости товаров по индексу
|
||||||
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
|
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
|
||||||
|
|
||||||
@ -108,7 +147,8 @@ export default function Catalog() {
|
|||||||
categoryName,
|
categoryName,
|
||||||
isPartsAPIMode,
|
isPartsAPIMode,
|
||||||
isPartsIndexMode,
|
isPartsIndexMode,
|
||||||
isPartsIndexCatalogOnly
|
isPartsIndexCatalogOnly,
|
||||||
|
'router.query': router.query
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загружаем артикулы PartsAPI
|
// Загружаем артикулы PartsAPI
|
||||||
@ -135,7 +175,7 @@ export default function Catalog() {
|
|||||||
catalogId: catalogId as string,
|
catalogId: catalogId as string,
|
||||||
groupId: groupId as string,
|
groupId: groupId as string,
|
||||||
lang: 'ru',
|
lang: 'ru',
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: PARTSINDEX_PAGE_SIZE,
|
||||||
page: partsIndexPage,
|
page: partsIndexPage,
|
||||||
q: searchQuery || undefined,
|
q: searchQuery || undefined,
|
||||||
params: undefined // Будем обновлять через refetch
|
params: undefined // Будем обновлять через refetch
|
||||||
@ -164,12 +204,25 @@ export default function Catalog() {
|
|||||||
// allEntities больше не используется - используем allLoadedEntities
|
// allEntities больше не используется - используем allLoadedEntities
|
||||||
|
|
||||||
// Хук для загрузки цен товаров PartsIndex
|
// Хук для загрузки цен товаров PartsIndex
|
||||||
const productsForPrices = visibleEntities.map(entity => ({
|
const { getPrice, isLoadingPrice, ensurePriceLoaded } = useProductPrices();
|
||||||
|
|
||||||
|
// Загружаем цены для видимых товаров PartsIndex (для отображения конкретных цен)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPartsIndexMode && visibleEntities.length > 0) {
|
||||||
|
// Загружаем цены только для видимых товаров для отображения точных цен
|
||||||
|
visibleEntities.forEach((entity, index) => {
|
||||||
|
const productForPrice = {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
code: entity.code,
|
code: entity.code,
|
||||||
brand: entity.brand.name
|
brand: entity.brand.name
|
||||||
}));
|
};
|
||||||
const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices);
|
// Загружаем с небольшой задержкой
|
||||||
|
setTimeout(() => {
|
||||||
|
ensurePriceLoaded(productForPrice);
|
||||||
|
}, index * 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isPartsIndexMode, visibleEntities, ensurePriceLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (articlesData?.partsAPIArticles) {
|
if (articlesData?.partsAPIArticles) {
|
||||||
@ -187,15 +240,19 @@ export default function Catalog() {
|
|||||||
console.log('📊 Обновляем entitiesData:', {
|
console.log('📊 Обновляем entitiesData:', {
|
||||||
listLength: entitiesData.partsIndexCatalogEntities.list.length,
|
listLength: entitiesData.partsIndexCatalogEntities.list.length,
|
||||||
pagination: entitiesData.partsIndexCatalogEntities.pagination,
|
pagination: entitiesData.partsIndexCatalogEntities.pagination,
|
||||||
currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1
|
currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1,
|
||||||
|
isFilterChanging
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Если изменяются фильтры, сбрасываем флаг после получения новых данных
|
||||||
|
if (isFilterChanging) {
|
||||||
|
setIsFilterChanging(false);
|
||||||
|
console.log('🔄 Сброшен флаг isFilterChanging - получены новые отфильтрованные данные');
|
||||||
|
}
|
||||||
|
|
||||||
const newEntities = entitiesData.partsIndexCatalogEntities.list;
|
const newEntities = entitiesData.partsIndexCatalogEntities.list;
|
||||||
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
|
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
|
||||||
|
|
||||||
// Обновляем список товаров
|
|
||||||
setVisibleEntities(newEntities);
|
|
||||||
|
|
||||||
// Обновляем информацию о пагинации
|
// Обновляем информацию о пагинации
|
||||||
const currentPage = pagination?.page?.current || 1;
|
const currentPage = pagination?.page?.current || 1;
|
||||||
const hasNext = pagination?.page?.next !== null;
|
const hasNext = pagination?.page?.next !== null;
|
||||||
@ -204,6 +261,24 @@ export default function Catalog() {
|
|||||||
setPartsIndexPage(currentPage);
|
setPartsIndexPage(currentPage);
|
||||||
setHasMoreEntities(hasNext);
|
setHasMoreEntities(hasNext);
|
||||||
|
|
||||||
|
// Сохраняем в кэш
|
||||||
|
setEntitiesCache(prev => new Map(prev).set(currentPage, newEntities));
|
||||||
|
|
||||||
|
// Если это первая страница или сброс, заменяем накопленные товары
|
||||||
|
if (currentPage === 1) {
|
||||||
|
setAccumulatedEntities(newEntities);
|
||||||
|
// Устанавливаем visibleEntities сразу, только если не идет изменение фильтров
|
||||||
|
if (!isFilterChanging) {
|
||||||
|
setVisibleEntities(newEntities);
|
||||||
|
console.log('✅ Установлены visibleEntities для первой страницы:', newEntities.length);
|
||||||
|
} else {
|
||||||
|
console.log('🔄 Пропускаем установку visibleEntities - фильтры изменяются');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Добавляем к накопленным товарам
|
||||||
|
setAccumulatedEntities(prev => [...prev, ...newEntities]);
|
||||||
|
}
|
||||||
|
|
||||||
// Вычисляем общее количество страниц (приблизительно)
|
// Вычисляем общее количество страниц (приблизительно)
|
||||||
if (hasNext) {
|
if (hasNext) {
|
||||||
setTotalPages(currentPage + 1); // Минимум еще одна страница
|
setTotalPages(currentPage + 1); // Минимум еще одна страница
|
||||||
@ -213,10 +288,10 @@ export default function Catalog() {
|
|||||||
|
|
||||||
console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev });
|
console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev });
|
||||||
}
|
}
|
||||||
}, [entitiesData]);
|
}, [entitiesData, isFilterChanging]);
|
||||||
|
|
||||||
// Преобразование выбранных фильтров в формат PartsIndex API
|
// Преобразование выбранных фильтров в формат PartsIndex API
|
||||||
const convertFiltersToPartsIndexParams = useCallback((): Record<string, any> => {
|
const convertFiltersToPartsIndexParams = useMemo((): Record<string, any> => {
|
||||||
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
|
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -241,6 +316,77 @@ export default function Catalog() {
|
|||||||
return apiParams;
|
return apiParams;
|
||||||
}, [paramsData, selectedFilters]);
|
}, [paramsData, selectedFilters]);
|
||||||
|
|
||||||
|
// Функция автоматической подгрузки дополнительных страниц PartsIndex
|
||||||
|
const autoLoadMoreEntities = useCallback(async () => {
|
||||||
|
if (isAutoLoading || !hasMoreEntities || !isPartsIndexMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔄 Автоподгрузка: проверяем товары с предложениями...');
|
||||||
|
|
||||||
|
// Восстанавливаем автоподгрузку
|
||||||
|
console.log('🔄 Автоподгрузка активна');
|
||||||
|
|
||||||
|
// Подсчитываем текущее количество товаров (все уже отфильтрованы на сервере)
|
||||||
|
const currentEntitiesCount = accumulatedEntities.length;
|
||||||
|
|
||||||
|
console.log('📊 Автоподгрузка: текущее состояние:', {
|
||||||
|
накопленоТоваров: currentEntitiesCount,
|
||||||
|
целевоеКоличество: ITEMS_PER_PAGE,
|
||||||
|
естьЕщеТовары: hasMoreEntities
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если у нас уже достаточно товаров, не загружаем
|
||||||
|
if (currentEntitiesCount >= ITEMS_PER_PAGE) {
|
||||||
|
console.log('✅ Автоподгрузка: достаточно товаров');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Даем время на загрузку цен товаров, если их слишком много загружается
|
||||||
|
const loadingCount = accumulatedEntities.filter(entity => {
|
||||||
|
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||||
|
return isLoadingPrice(productForPrice);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// Ждем только если загружается больше 5 товаров одновременно
|
||||||
|
if (loadingCount > 5) {
|
||||||
|
console.log('⏳ Автоподгрузка: ждем загрузки цен для', loadingCount, 'товаров (больше 5)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если накопили уже много товаров, но мало с предложениями - прекращаем попытки
|
||||||
|
if (accumulatedEntities.length >= ITEMS_PER_PAGE * 8) { // Увеличили лимит с 4 до 8 страниц
|
||||||
|
console.log('⚠️ Автоподгрузка: достигли лимита попыток, прекращаем');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutoLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 Автоподгрузка: загружаем следующую страницу PartsIndex...');
|
||||||
|
|
||||||
|
const apiParams = convertFiltersToPartsIndexParams;
|
||||||
|
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
|
||||||
|
|
||||||
|
const result = await refetchEntities({
|
||||||
|
catalogId: catalogId as string,
|
||||||
|
groupId: groupId as string,
|
||||||
|
lang: 'ru',
|
||||||
|
limit: PARTSINDEX_PAGE_SIZE,
|
||||||
|
page: partsIndexPage + 1,
|
||||||
|
q: searchQuery || undefined,
|
||||||
|
params: paramsString
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Автоподгрузка: страница загружена, результат:', result.data?.partsIndexCatalogEntities?.list?.length || 0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Автоподгрузка: ошибка загрузки следующей страницы:', error);
|
||||||
|
} finally {
|
||||||
|
setIsAutoLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAutoLoading, hasMoreEntities, isPartsIndexMode, accumulatedEntities.length, partsIndexPage, refetchEntities, catalogId, groupId, searchQuery]);
|
||||||
|
|
||||||
// Генерация фильтров для PartsIndex на основе параметров API
|
// Генерация фильтров для PartsIndex на основе параметров API
|
||||||
const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
|
const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
|
||||||
if (!paramsData?.partsIndexCatalogParams?.list) {
|
if (!paramsData?.partsIndexCatalogParams?.list) {
|
||||||
@ -292,6 +438,91 @@ export default function Catalog() {
|
|||||||
}
|
}
|
||||||
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]);
|
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]);
|
||||||
|
|
||||||
|
// Автоматическая подгрузка товаров с задержкой для загрузки цен
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPartsIndexMode || accumulatedEntities.length === 0 || isAutoLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Даем время на загрузку цен (3 секунды после последнего изменения)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
autoLoadMoreEntities();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isPartsIndexMode, accumulatedEntities.length, isAutoLoading]);
|
||||||
|
|
||||||
|
// Дополнительный триггер автоподгрузки при изменении количества товаров с предложениями
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🔍 Проверка триггера автоподгрузки:', {
|
||||||
|
isPartsIndexMode,
|
||||||
|
entitiesWithOffersLength: entitiesWithOffers.length,
|
||||||
|
isAutoLoading,
|
||||||
|
hasMoreEntities,
|
||||||
|
targetItemsPerPage: ITEMS_PER_PAGE
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPartsIndexMode || entitiesWithOffers.length === 0 || isAutoLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если товаров с предложениями мало, запускаем автоподгрузку через 1 секунду
|
||||||
|
if (entitiesWithOffers.length < ITEMS_PER_PAGE && hasMoreEntities) {
|
||||||
|
console.log('🚀 Запускаем автоподгрузку: товаров', entitiesWithOffers.length, 'из', ITEMS_PER_PAGE);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('🚀 Дополнительная автоподгрузка: недостаточно товаров с предложениями');
|
||||||
|
autoLoadMoreEntities();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Автоподгрузка не нужна: товаров достаточно или нет больше данных');
|
||||||
|
}
|
||||||
|
}, [isPartsIndexMode, entitiesWithOffers.length, hasMoreEntities, isAutoLoading]);
|
||||||
|
|
||||||
|
// Обновляем список товаров при изменении накопленных товаров (серверная фильтрация)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPartsIndexMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если фильтры изменяются, не обновляем отображение старых данных
|
||||||
|
if (isFilterChanging) {
|
||||||
|
console.log('🔄 Пропускаем обновление entitiesWithOffers - фильтры изменяются');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все товары уже отфильтрованы на сервере - показываем все накопленные
|
||||||
|
const entitiesWithOffers = accumulatedEntities;
|
||||||
|
|
||||||
|
console.log('📊 Обновляем entitiesWithOffers (серверная фильтрация):', {
|
||||||
|
накопленоТоваров: accumulatedEntities.length,
|
||||||
|
отображаемыхТоваров: entitiesWithOffers.length,
|
||||||
|
целевоеКоличество: ITEMS_PER_PAGE,
|
||||||
|
isFilterChanging
|
||||||
|
});
|
||||||
|
|
||||||
|
setEntitiesWithOffers(entitiesWithOffers);
|
||||||
|
|
||||||
|
// Показываем товары для текущей пользовательской страницы
|
||||||
|
const startIndex = (currentUserPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
const visibleForCurrentPage = entitiesWithOffers.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
console.log('📊 Обновляем visibleEntities:', {
|
||||||
|
currentUserPage,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
visibleForCurrentPage: visibleForCurrentPage.length,
|
||||||
|
entitiesWithOffers: entitiesWithOffers.length
|
||||||
|
});
|
||||||
|
|
||||||
|
setVisibleEntities(visibleForCurrentPage);
|
||||||
|
|
||||||
|
}, [isPartsIndexMode, accumulatedEntities, currentUserPage, isFilterChanging]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Генерируем динамические фильтры для PartsAPI
|
// Генерируем динамические фильтры для PartsAPI
|
||||||
const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
|
const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
|
||||||
if (!allArticles.length) return [];
|
if (!allArticles.length) return [];
|
||||||
@ -377,27 +608,94 @@ export default function Catalog() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Функция для обновления URL с фильтрами
|
||||||
|
const updateUrlWithFilters = useCallback((filters: {[key: string]: string[]}, search: string) => {
|
||||||
|
const query: any = { ...router.query };
|
||||||
|
|
||||||
|
// Удаляем старые фильтры из URL
|
||||||
|
Object.keys(query).forEach(key => {
|
||||||
|
if (key.startsWith('filter_') || key === 'q') {
|
||||||
|
delete query[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем новые фильтры
|
||||||
|
Object.entries(filters).forEach(([filterName, values]) => {
|
||||||
|
if (values.length > 0) {
|
||||||
|
query[`filter_${filterName}`] = values.length === 1 ? values[0] : values;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем поисковый запрос
|
||||||
|
if (search.trim()) {
|
||||||
|
query.q = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем URL без перезагрузки страницы
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query
|
||||||
|
}, undefined, { shallow: true });
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => {
|
const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => {
|
||||||
setSelectedFilters(prev => ({
|
setSelectedFilters(prev => {
|
||||||
...prev,
|
const newFilters = { ...prev };
|
||||||
[filterTitle]: Array.isArray(value) ? value : [value]
|
|
||||||
}));
|
// Если значение пустое (пустой массив или пустая строка), удаляем фильтр
|
||||||
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
|
delete newFilters[filterTitle];
|
||||||
|
} else if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
delete newFilters[filterTitle];
|
||||||
|
} else {
|
||||||
|
// Иначе устанавливаем значение
|
||||||
|
newFilters[filterTitle] = Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем URL
|
||||||
|
updateUrlWithFilters(newFilters, searchQuery);
|
||||||
|
|
||||||
|
return newFilters;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMobileFilterChange = (type: string, value: any) => {
|
const handleMobileFilterChange = (type: string, value: any) => {
|
||||||
setSelectedFilters(prev => ({
|
setSelectedFilters(prev => {
|
||||||
...prev,
|
const newFilters = { ...prev };
|
||||||
[type]: Array.isArray(value) ? value : [value]
|
|
||||||
}));
|
// Если значение пустое (пустой массив или пустая строка), удаляем фильтр
|
||||||
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
|
delete newFilters[type];
|
||||||
|
} else if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
delete newFilters[type];
|
||||||
|
} else {
|
||||||
|
// Иначе устанавливаем значение
|
||||||
|
newFilters[type] = Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем URL
|
||||||
|
updateUrlWithFilters(newFilters, searchQuery);
|
||||||
|
|
||||||
|
return newFilters;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработчик изменения поискового запроса
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
updateUrlWithFilters(selectedFilters, value);
|
||||||
|
}, [selectedFilters, updateUrlWithFilters]);
|
||||||
|
|
||||||
// Функция для сброса всех фильтров
|
// Функция для сброса всех фильтров
|
||||||
const handleResetFilters = useCallback(() => {
|
const handleResetFilters = useCallback(() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedFilters({});
|
setSelectedFilters({});
|
||||||
setShowAllBrands(false);
|
setShowAllBrands(false);
|
||||||
setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую
|
setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую
|
||||||
}, []);
|
|
||||||
|
// Очищаем URL от фильтров
|
||||||
|
updateUrlWithFilters({}, '');
|
||||||
|
}, [updateUrlWithFilters]);
|
||||||
|
|
||||||
// Фильтрация по поиску и фильтрам для PartsAPI
|
// Фильтрация по поиску и фильтрам для PartsAPI
|
||||||
const filteredArticles = useMemo(() => {
|
const filteredArticles = useMemo(() => {
|
||||||
@ -431,9 +729,6 @@ export default function Catalog() {
|
|||||||
});
|
});
|
||||||
}, [allArticles, searchQuery, selectedFilters]);
|
}, [allArticles, searchQuery, selectedFilters]);
|
||||||
|
|
||||||
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
|
|
||||||
const filteredEntities = visibleEntities;
|
|
||||||
|
|
||||||
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
|
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPartsAPIMode) {
|
if (isPartsAPIMode) {
|
||||||
@ -457,13 +752,47 @@ export default function Catalog() {
|
|||||||
// Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
|
// Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
|
||||||
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
|
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
|
||||||
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
|
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
|
||||||
setPartsIndexPage(1);
|
|
||||||
setHasMoreEntities(true);
|
|
||||||
|
|
||||||
// Перезагружаем данные с новыми параметрами фильтрации
|
// Устанавливаем флаг изменения фильтров
|
||||||
const apiParams = convertFiltersToPartsIndexParams();
|
setIsFilterChanging(true);
|
||||||
|
|
||||||
|
setPartsIndexPage(1);
|
||||||
|
setCurrentUserPage(1);
|
||||||
|
setHasMoreEntities(true);
|
||||||
|
setAccumulatedEntities([]);
|
||||||
|
setEntitiesWithOffers([]);
|
||||||
|
setEntitiesCache(new Map());
|
||||||
|
|
||||||
|
// Вычисляем параметры фильтрации прямо здесь, чтобы избежать зависимости от useMemo
|
||||||
|
let apiParams: Record<string, any> = {};
|
||||||
|
if (paramsData?.partsIndexCatalogParams?.list && Object.keys(selectedFilters).length > 0) {
|
||||||
|
paramsData.partsIndexCatalogParams.list.forEach((param: any) => {
|
||||||
|
const selectedValues = selectedFilters[param.name];
|
||||||
|
if (selectedValues && selectedValues.length > 0) {
|
||||||
|
// Находим соответствующие значения из API данных
|
||||||
|
const matchingValues = param.values.filter((value: any) =>
|
||||||
|
selectedValues.includes(value.title || value.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingValues.length > 0) {
|
||||||
|
// Используем ID параметра из API и значения
|
||||||
|
apiParams[param.id] = matchingValues.map((v: any) => v.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
|
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
|
||||||
|
|
||||||
|
console.log('🔄 Запуск refetch с новыми фильтрами:', {
|
||||||
|
searchQuery,
|
||||||
|
selectedFilters,
|
||||||
|
apiParams,
|
||||||
|
paramsString,
|
||||||
|
catalogId,
|
||||||
|
groupId
|
||||||
|
});
|
||||||
|
|
||||||
// Также обновляем параметры фильтрации
|
// Также обновляем параметры фильтрации
|
||||||
refetchParams({
|
refetchParams({
|
||||||
catalogId: catalogId as string,
|
catalogId: catalogId as string,
|
||||||
@ -471,21 +800,34 @@ export default function Catalog() {
|
|||||||
lang: 'ru',
|
lang: 'ru',
|
||||||
q: searchQuery || undefined,
|
q: searchQuery || undefined,
|
||||||
params: paramsString
|
params: paramsString
|
||||||
|
}).then(result => {
|
||||||
|
console.log('✅ refetchParams результат:', result);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('❌ refetchParams ошибка:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
refetchEntities({
|
refetchEntities({
|
||||||
catalogId: catalogId as string,
|
catalogId: catalogId as string,
|
||||||
groupId: groupId as string,
|
groupId: groupId as string,
|
||||||
lang: 'ru',
|
lang: 'ru',
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: PARTSINDEX_PAGE_SIZE,
|
||||||
page: 1,
|
page: 1,
|
||||||
q: searchQuery || undefined,
|
q: searchQuery || undefined,
|
||||||
params: paramsString
|
params: paramsString
|
||||||
|
}).then(result => {
|
||||||
|
console.log('✅ refetchEntities результат:', result.data?.partsIndexCatalogEntities?.list?.length || 0, 'товаров');
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('❌ refetchEntities ошибка:', error);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Если нет активных фильтров, сбрасываем флаг
|
||||||
|
if (isFilterChanging) {
|
||||||
|
setIsFilterChanging(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters), refetchEntities, refetchParams, convertFiltersToPartsIndexParams]);
|
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters), paramsData]);
|
||||||
|
|
||||||
// Управляем показом пустого состояния с задержкой
|
// Управляем показом пустого состояния с задержкой
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -503,26 +845,61 @@ export default function Catalog() {
|
|||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
|
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
|
||||||
// Для PartsIndex показываем пустое состояние если нет товаров
|
// Для PartsIndex показываем пустое состояние если нет товаров И данные уже загружены
|
||||||
setShowEmptyState(visibleEntities.length === 0);
|
const hasLoadedData = accumulatedEntities.length > 0 || Boolean(entitiesData?.partsIndexCatalogEntities?.list);
|
||||||
|
|
||||||
|
// Показываем пустое состояние если данные загружены и нет видимых товаров
|
||||||
|
// (товары уже отфильтрованы на сервере, поэтому не нужно ждать загрузки цен)
|
||||||
|
const shouldShowEmpty = hasLoadedData && visibleEntities.length === 0;
|
||||||
|
setShowEmptyState(shouldShowEmpty);
|
||||||
|
|
||||||
|
console.log('📊 Определяем showEmptyState для PartsIndex (серверная фильтрация):', {
|
||||||
|
hasLoadedData,
|
||||||
|
visibleEntitiesLength: visibleEntities.length,
|
||||||
|
accumulatedEntitiesLength: accumulatedEntities.length,
|
||||||
|
shouldShowEmpty,
|
||||||
|
showEmptyState: shouldShowEmpty
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setShowEmptyState(false);
|
setShowEmptyState(false);
|
||||||
}
|
}
|
||||||
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length,
|
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length,
|
||||||
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, filteredEntities.length]);
|
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, accumulatedEntities.length, entitiesData]);
|
||||||
|
|
||||||
// Функции для навигации по страницам PartsIndex
|
// Функции для навигации по пользовательским страницам
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
if (hasMoreEntities && !entitiesLoading) {
|
const maxUserPage = Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE);
|
||||||
setPartsIndexPage(prev => prev + 1);
|
console.log('🔄 Нажата кнопка "Вперед":', {
|
||||||
|
currentUserPage,
|
||||||
|
maxUserPage,
|
||||||
|
accumulatedEntitiesLength: accumulatedEntities.length,
|
||||||
|
ITEMS_PER_PAGE
|
||||||
|
});
|
||||||
|
if (currentUserPage < maxUserPage) {
|
||||||
|
setCurrentUserPage(prev => {
|
||||||
|
console.log('✅ Переходим на страницу:', prev + 1);
|
||||||
|
return prev + 1;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Нельзя перейти вперед: уже на последней странице');
|
||||||
}
|
}
|
||||||
}, [hasMoreEntities, entitiesLoading]);
|
}, [currentUserPage, accumulatedEntities.length]);
|
||||||
|
|
||||||
const handlePrevPage = useCallback(() => {
|
const handlePrevPage = useCallback(() => {
|
||||||
if (partsIndexPage > 1 && !entitiesLoading) {
|
console.log('🔄 Нажата кнопка "Назад":', {
|
||||||
setPartsIndexPage(prev => prev - 1);
|
currentUserPage,
|
||||||
|
accumulatedEntitiesLength: accumulatedEntities.length
|
||||||
|
});
|
||||||
|
if (currentUserPage > 1) {
|
||||||
|
setCurrentUserPage(prev => {
|
||||||
|
const newPage = prev - 1;
|
||||||
|
console.log('✅ Переходим на страницу:', newPage);
|
||||||
|
return newPage;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Нельзя перейти назад: уже на первой странице');
|
||||||
}
|
}
|
||||||
}, [partsIndexPage, entitiesLoading]);
|
}, [currentUserPage, accumulatedEntities.length]);
|
||||||
|
|
||||||
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
|
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
|
||||||
const handleLoadMorePartsAPI = useCallback(async () => {
|
const handleLoadMorePartsAPI = useCallback(async () => {
|
||||||
@ -592,9 +969,7 @@ export default function Catalog() {
|
|||||||
isPartsAPIMode ?
|
isPartsAPIMode ?
|
||||||
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
|
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
|
||||||
isPartsIndexMode ?
|
isPartsIndexMode ?
|
||||||
(searchQuery.trim() || Object.keys(selectedFilters).length > 0 ?
|
entitiesWithOffers.length :
|
||||||
filteredEntities.length :
|
|
||||||
entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) :
|
|
||||||
3587
|
3587
|
||||||
}
|
}
|
||||||
productName={
|
productName={
|
||||||
@ -638,7 +1013,7 @@ export default function Catalog() {
|
|||||||
onFilterChange={handleDesktopFilterChange}
|
onFilterChange={handleDesktopFilterChange}
|
||||||
filterValues={selectedFilters}
|
filterValues={selectedFilters}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={handleSearchChange}
|
||||||
isLoading={filtersGenerating}
|
isLoading={filtersGenerating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -649,7 +1024,7 @@ export default function Catalog() {
|
|||||||
onFilterChange={handleDesktopFilterChange}
|
onFilterChange={handleDesktopFilterChange}
|
||||||
filterValues={selectedFilters}
|
filterValues={selectedFilters}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={handleSearchChange}
|
||||||
isLoading={filtersLoading}
|
isLoading={filtersLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -660,7 +1035,7 @@ export default function Catalog() {
|
|||||||
onFilterChange={handleDesktopFilterChange}
|
onFilterChange={handleDesktopFilterChange}
|
||||||
filterValues={selectedFilters}
|
filterValues={selectedFilters}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={handleSearchChange}
|
||||||
isLoading={filtersLoading}
|
isLoading={filtersLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -670,7 +1045,7 @@ export default function Catalog() {
|
|||||||
onClose={() => setShowFiltersMobile(false)}
|
onClose={() => setShowFiltersMobile(false)}
|
||||||
filters={isPartsAPIMode ? dynamicFilters : catalogFilters}
|
filters={isPartsAPIMode ? dynamicFilters : catalogFilters}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={handleSearchChange}
|
||||||
filterValues={selectedFilters}
|
filterValues={selectedFilters}
|
||||||
onFilterChange={handleMobileFilterChange}
|
onFilterChange={handleMobileFilterChange}
|
||||||
/>
|
/>
|
||||||
@ -690,6 +1065,8 @@ export default function Catalog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Сообщение об ошибке */}
|
{/* Сообщение об ошибке */}
|
||||||
{isPartsAPIMode && articlesError && (
|
{isPartsAPIMode && articlesError && (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
@ -739,36 +1116,45 @@ export default function Catalog() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Показываем индикатор загрузки при изменении фильтров */}
|
||||||
|
{isPartsIndexMode && isFilterChanging && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<LoadingSpinner />
|
||||||
|
<div className="text-gray-500 text-lg mt-4">Применяем фильтры...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Отображение товаров PartsIndex */}
|
{/* Отображение товаров PartsIndex */}
|
||||||
{isPartsIndexMode && filteredEntities.length > 0 && (
|
{isPartsIndexMode && !isFilterChanging && (() => {
|
||||||
|
console.log('🎯 Проверяем отображение PartsIndex товаров:', {
|
||||||
|
isPartsIndexMode,
|
||||||
|
visibleEntitiesLength: visibleEntities.length,
|
||||||
|
visibleEntities: visibleEntities.map(e => ({ id: e.id, code: e.code, brand: e.brand.name })),
|
||||||
|
isFilterChanging
|
||||||
|
});
|
||||||
|
return visibleEntities.length > 0;
|
||||||
|
})() && (
|
||||||
<>
|
<>
|
||||||
{filteredEntities
|
{visibleEntities
|
||||||
.map((entity, idx) => {
|
.map((entity, idx) => {
|
||||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||||
const priceData = getPrice(productForPrice);
|
const priceData = getPrice(productForPrice);
|
||||||
const isLoadingPriceData = isLoadingPrice(productForPrice);
|
const isLoadingPriceData = isLoadingPrice(productForPrice);
|
||||||
|
|
||||||
return {
|
// Определяем цену для отображения (все товары уже отфильтрованы на сервере)
|
||||||
entity,
|
let displayPrice = "";
|
||||||
idx,
|
|
||||||
productForPrice,
|
|
||||||
priceData,
|
|
||||||
isLoadingPriceData,
|
|
||||||
hasOffer: priceData !== null || isLoadingPriceData
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(item => item.hasOffer) // Показываем только товары с предложениями или загружающиеся
|
|
||||||
.map(({ entity, idx, productForPrice, priceData, isLoadingPriceData }) => {
|
|
||||||
// Определяем цену для отображения
|
|
||||||
let displayPrice = "Цена по запросу";
|
|
||||||
let displayCurrency = "RUB";
|
let displayCurrency = "RUB";
|
||||||
let priceElement;
|
let priceElement;
|
||||||
|
|
||||||
if (isLoadingPriceData) {
|
if (isLoadingPriceData) {
|
||||||
|
// Показываем скелетон загрузки вместо текста
|
||||||
priceElement = <PriceSkeleton />;
|
priceElement = <PriceSkeleton />;
|
||||||
} else if (priceData && priceData.price) {
|
} else if (priceData && priceData.price) {
|
||||||
displayPrice = `${priceData.price.toLocaleString('ru-RU')} ₽`;
|
displayPrice = `${priceData.price.toLocaleString('ru-RU')} ₽`;
|
||||||
displayCurrency = priceData.currency || "RUB";
|
displayCurrency = priceData.currency || "RUB";
|
||||||
|
} else {
|
||||||
|
// Если нет данных о цене, показываем скелетон (товар должен загрузиться)
|
||||||
|
priceElement = <PriceSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -779,7 +1165,7 @@ export default function Catalog() {
|
|||||||
articleNumber={entity.code}
|
articleNumber={entity.code}
|
||||||
brandName={entity.brand.name}
|
brandName={entity.brand.name}
|
||||||
image={entity.images?.[0] || ''}
|
image={entity.images?.[0] || ''}
|
||||||
price={isLoadingPriceData ? "" : displayPrice}
|
price={priceElement ? "" : displayPrice}
|
||||||
priceElement={priceElement}
|
priceElement={priceElement}
|
||||||
oldPrice=""
|
oldPrice=""
|
||||||
discount=""
|
discount=""
|
||||||
@ -787,10 +1173,11 @@ export default function Catalog() {
|
|||||||
productId={entity.id}
|
productId={entity.id}
|
||||||
artId={entity.id}
|
artId={entity.id}
|
||||||
offerKey={priceData?.offerKey}
|
offerKey={priceData?.offerKey}
|
||||||
|
isInCart={priceData?.isInCart}
|
||||||
onAddToCart={async () => {
|
onAddToCart={async () => {
|
||||||
// Если цена не загружена, загружаем её и добавляем в корзину
|
// Если цена не загружена, загружаем её и добавляем в корзину
|
||||||
if (!priceData && !isLoadingPriceData) {
|
if (!priceData && !isLoadingPriceData) {
|
||||||
loadPriceOnDemand(productForPrice);
|
ensurePriceLoaded(productForPrice);
|
||||||
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
|
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -843,40 +1230,61 @@ export default function Catalog() {
|
|||||||
{/* Пагинация для PartsIndex */}
|
{/* Пагинация для PartsIndex */}
|
||||||
<div className="w-layout-hflex pagination">
|
<div className="w-layout-hflex pagination">
|
||||||
<button
|
<button
|
||||||
onClick={handlePrevPage}
|
onClick={() => {
|
||||||
disabled={partsIndexPage <= 1 || entitiesLoading}
|
console.log('🖱️ Клик по кнопке "Назад"');
|
||||||
|
handlePrevPage();
|
||||||
|
}}
|
||||||
|
disabled={currentUserPage <= 1}
|
||||||
className="button_strock w-button mr-2"
|
className="button_strock w-button mr-2"
|
||||||
>
|
>
|
||||||
← Назад
|
← Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="flex items-center px-4 text-gray-600">
|
<span className="flex items-center px-4 text-gray-600">
|
||||||
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
|
Страница {currentUserPage} из {Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE) || 1}
|
||||||
|
{isAutoLoading && ' (загружаем...)'}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">
|
||||||
|
(товаров: {accumulatedEntities.length})
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNextPage}
|
onClick={() => {
|
||||||
disabled={!hasMoreEntities || entitiesLoading}
|
console.log('🖱️ Клик по кнопке "Вперед"');
|
||||||
|
handleNextPage();
|
||||||
|
}}
|
||||||
|
disabled={currentUserPage >= Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE)}
|
||||||
className="button_strock w-button ml-2"
|
className="button_strock w-button ml-2"
|
||||||
>
|
>
|
||||||
{entitiesLoading ? 'Загрузка...' : 'Вперед →'}
|
Вперед →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Отладочная информация */}
|
{/* Отладочная информация */}
|
||||||
{isPartsIndexMode && (
|
{isPartsIndexMode && (
|
||||||
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
|
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
|
||||||
<div>🔍 Отладка PartsIndex:</div>
|
<div>🔍 Отладка PartsIndex (исправленная логика):</div>
|
||||||
<div>• hasMoreItems: {hasMoreItems ? 'да' : 'нет'}</div>
|
<div>• accumulatedEntities: {accumulatedEntities.length}</div>
|
||||||
<div>• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
|
<div>• entitiesWithOffers: {entitiesWithOffers.length}</div>
|
||||||
<div>• entitiesPage: {entitiesPage}</div>
|
|
||||||
<div>• visibleEntities: {visibleEntities.length}</div>
|
<div>• visibleEntities: {visibleEntities.length}</div>
|
||||||
<div>• filteredEntities: {filteredEntities.length}</div>
|
<div>• currentUserPage: {currentUserPage}</div>
|
||||||
<div>• groupId: {groupId || 'отсутствует'}</div>
|
<div>• partsIndexPage (API): {partsIndexPage}</div>
|
||||||
<div>• isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div>
|
<div>• isAutoLoading: {isAutoLoading ? 'да' : 'нет'}</div>
|
||||||
|
<div>• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
|
||||||
<div>• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
|
<div>• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
|
||||||
<div>• catalogId: {catalogId || 'отсутствует'}</div>
|
<div>• groupId: {groupId || 'отсутствует'}</div>
|
||||||
<div>• Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</div>
|
<div>• Target: {ITEMS_PER_PAGE} товаров на страницу</div>
|
||||||
|
<div>• showEmptyState: {showEmptyState ? 'да' : 'нет'}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
console.log('🔧 Ручной запуск автоподгрузки');
|
||||||
|
autoLoadMoreEntities();
|
||||||
|
}}
|
||||||
|
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded"
|
||||||
|
disabled={isAutoLoading}
|
||||||
|
>
|
||||||
|
{isAutoLoading ? 'Загружаем...' : 'Загрузить еще'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -892,7 +1300,16 @@ export default function Catalog() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Пустое состояние для PartsIndex */}
|
{/* Пустое состояние для PartsIndex */}
|
||||||
{isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && (
|
{isPartsIndexMode && !entitiesLoading && !entitiesError && (() => {
|
||||||
|
console.log('🎯 Проверяем пустое состояние PartsIndex:', {
|
||||||
|
isPartsIndexMode,
|
||||||
|
entitiesLoading,
|
||||||
|
entitiesError,
|
||||||
|
showEmptyState,
|
||||||
|
visibleEntitiesLength: visibleEntities.length
|
||||||
|
});
|
||||||
|
return showEmptyState;
|
||||||
|
})() && (
|
||||||
<CatalogEmptyState
|
<CatalogEmptyState
|
||||||
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
|
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
|
||||||
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
|
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
|
||||||
|
146
src/pages/confidentiality.tsx
Normal file
146
src/pages/confidentiality.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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 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 Confidentiality() {
|
||||||
|
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} />
|
||||||
|
<section className="section-info">
|
||||||
|
<div className="w-layout-blockcontainer container info w-container">
|
||||||
|
<div className="w-layout-vflex flex-block-9">
|
||||||
|
<div className="w-layout-hflex flex-block-7">
|
||||||
|
<a href="#" className="link-block w-inline-block">
|
||||||
|
<div>Главная</div>
|
||||||
|
</a>
|
||||||
|
<div className="text-block-3">→</div>
|
||||||
|
<a href="#" className="link-block-2 w-inline-block">
|
||||||
|
<div>Политика конфиденциальности</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="w-layout-hflex flex-block-8">
|
||||||
|
<div className="w-layout-hflex flex-block-10">
|
||||||
|
<h1 className="heading">Политика конфиденциальности</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="flex relative gap-8 items-start self-stretch pt-10 pb-20 max-md:p-8 max-sm:gap-5 max-sm:p-5">
|
||||||
|
<div className="flex relative flex-col gap-8 items-start p-10 bg-white rounded-3xl flex-[1_0_0] max-w-[1580px] mx-auto max-md:p-8 max-sm:gap-5 max-sm:p-5">
|
||||||
|
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4">
|
||||||
|
<div
|
||||||
|
layer-name="Объявлен старт продаж электрических насосов"
|
||||||
|
className="relative self-stretch text-3xl font-bold leading-9 text-gray-950"
|
||||||
|
>
|
||||||
|
Объявлен старт продаж электрических насосов
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
layer-name="Бренд вывел на рынок сразу широкий ассортимент, уже на старте продаж - более 100 артикулов и включает в себя позиции для брендов-лидеров автомобильного рынка, например: артикул 77WPE080 для Mercedes-Benz S-CLASS (W221, C216), артикул 77WPE096 – Land Rover DISCOVERY V (L462) / Jaguar F-PACE (X761), артикул 77WPE014 – Audi Q5 (8RB) / Volkswagen TOUAREG (7P5, 7P6)."
|
||||||
|
className="relative self-stretch text-base leading-6 text-gray-600 max-sm:text-sm"
|
||||||
|
>
|
||||||
|
Бренд вывел на рынок сразу широкий ассортимент, уже на старте
|
||||||
|
продаж - более 100 артикулов и включает в себя позиции для
|
||||||
|
брендов-лидеров автомобильного рынка, например: артикул 77WPE080
|
||||||
|
для Mercedes-Benz S-CLASS (W221, C216), артикул 77WPE096 – Land
|
||||||
|
Rover DISCOVERY V (L462) / Jaguar F-PACE (X761), артикул 77WPE014
|
||||||
|
– Audi Q5 (8RB) / Volkswagen TOUAREG (7P5, 7P6).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex relative flex-col gap-8 items-start self-stretch max-sm:gap-5">
|
||||||
|
<div
|
||||||
|
layer-name="Преимущества электрических насосов охлаждающей жидкости MasterKit Electro:"
|
||||||
|
className="relative self-stretch text-3xl font-medium leading-9 text-gray-950"
|
||||||
|
>
|
||||||
|
Преимущества электрических насосов охлаждающей жидкости MasterKit
|
||||||
|
Electro:
|
||||||
|
</div>
|
||||||
|
<div className="flex relative flex-col gap-3.5 items-start self-stretch">
|
||||||
|
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
|
||||||
|
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
layer-name="Отличная производительность за счёт применения компонентов известных мировых брендов."
|
||||||
|
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
|
||||||
|
>
|
||||||
|
Отличная производительность за счёт применения компонентов
|
||||||
|
известных мировых брендов.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
|
||||||
|
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
layer-name="Герметичность и устойчивость к коррозии"
|
||||||
|
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
|
||||||
|
>
|
||||||
|
Герметичность и устойчивость к коррозии
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
|
||||||
|
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
layer-name="Высококачественные материалы компонентов, обеспечивающие долгий срок службы"
|
||||||
|
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
|
||||||
|
>
|
||||||
|
Высококачественные материалы компонентов, обеспечивающие
|
||||||
|
долгий срок службы
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
|
||||||
|
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
|
||||||
|
<div
|
||||||
|
layer-name="Широкий ассортимент – более 100 артикулов"
|
||||||
|
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
|
||||||
|
>
|
||||||
|
Широкий ассортимент – более 100 артикулов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
layer-name="На электрические насосы системы охлаждения MasterKit Electro предоставляется гарантия 1 год или 30.000 км пробега, в зависимости от того, что наступит раньше. Все новинки уже внесены в каталог подбора продукции и доступны для заказа."
|
||||||
|
className="relative self-stretch text-base leading-6 text-gray-600 max-sm:text-sm"
|
||||||
|
>
|
||||||
|
На электрические насосы системы охлаждения MasterKit Electro
|
||||||
|
предоставляется гарантия 1 год или 30.000 км пробега, в
|
||||||
|
зависимости от того, что наступит раньше. Все новинки уже внесены
|
||||||
|
в каталог подбора продукции и доступны для заказа.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
layer-name="ABig_Button"
|
||||||
|
data-component-name="ABig_Button"
|
||||||
|
data-variant-name="Button big=Default"
|
||||||
|
className="relative gap-2.5 px-10 py-6 text-lg font-medium leading-5 text-center text-white no-underline bg-red-600 rounded-xl transition-all cursor-pointer border-[none] duration-[0.2s] ease-[ease] w-fit max-sm:px-8 max-sm:py-5 max-sm:w-full hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Перейти к товару
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="section-3">
|
||||||
|
<CatalogSubscribe />
|
||||||
|
</section>
|
||||||
|
<Footer />
|
||||||
|
<MobileMenuBottomSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -17,6 +17,7 @@ import MetaTags from "@/components/MetaTags";
|
|||||||
import { getMetaByPath } from "@/lib/meta-config";
|
import { getMetaByPath } from "@/lib/meta-config";
|
||||||
import JsonLdScript from "@/components/JsonLdScript";
|
import JsonLdScript from "@/components/JsonLdScript";
|
||||||
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
||||||
|
import HeroSlider from "@/components/index/HeroSlider";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const metaData = getMetaByPath('/');
|
const metaData = getMetaByPath('/');
|
||||||
|
@ -36,10 +36,10 @@ const ProfileHistoryPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MetaTags {...metaData} />
|
<MetaTags {...metaData} />
|
||||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
<div className="page-wrapper">
|
||||||
<ProfileInfo />
|
<ProfileInfo />
|
||||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5 h-full flex-1">
|
<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 min-h-[526px] max-w-[1580px] w-full h-full">
|
<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">
|
||||||
<LKMenu ref={menuRef} />
|
<LKMenu ref={menuRef} />
|
||||||
<ProfileHistoryMain />
|
<ProfileHistoryMain />
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,16 +16,29 @@ import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
|
|||||||
import { SEARCH_PRODUCT_OFFERS, GET_ANALOG_OFFERS } from "@/lib/graphql";
|
import { SEARCH_PRODUCT_OFFERS, GET_ANALOG_OFFERS } from "@/lib/graphql";
|
||||||
import { useArticleImage } from "@/hooks/useArticleImage";
|
import { useArticleImage } from "@/hooks/useArticleImage";
|
||||||
import { usePartsIndexEntityInfo } from "@/hooks/usePartsIndex";
|
import { usePartsIndexEntityInfo } from "@/hooks/usePartsIndex";
|
||||||
|
import { useCart } from "@/contexts/CartContext";
|
||||||
import MetaTags from "@/components/MetaTags";
|
import MetaTags from "@/components/MetaTags";
|
||||||
import { createProductMeta } from "@/lib/meta-config";
|
import { createProductMeta } from "@/lib/meta-config";
|
||||||
|
|
||||||
const ANALOGS_CHUNK_SIZE = 5;
|
const ANALOGS_CHUNK_SIZE = 5;
|
||||||
|
|
||||||
const sortOptions = [
|
// Функция для расчета даты доставки
|
||||||
"По цене",
|
const calculateDeliveryDate = (deliveryDays: number): string => {
|
||||||
"По рейтингу",
|
const today = new Date();
|
||||||
"По количеству"
|
const deliveryDate = new Date(today);
|
||||||
];
|
deliveryDate.setDate(today.getDate() + deliveryDays);
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||||
|
];
|
||||||
|
|
||||||
|
const day = deliveryDate.getDate();
|
||||||
|
const month = months[deliveryDate.getMonth()];
|
||||||
|
const year = deliveryDate.getFullYear();
|
||||||
|
|
||||||
|
return `${day} ${month} ${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Функция для создания динамических фильтров
|
// Функция для создания динамических фильтров
|
||||||
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
|
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
|
||||||
@ -175,15 +188,39 @@ const getBestOffers = (offers: any[]) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformOffersForCard = (offers: any[]) => {
|
// Убрано: функция сортировки теперь в CoreProductCard
|
||||||
|
|
||||||
|
// Функция для проверки наличия товара на складе
|
||||||
|
const checkProductStock = (result: any): boolean => {
|
||||||
|
if (!result) return false;
|
||||||
|
|
||||||
|
// Используем новые данные stockCalculation если доступны
|
||||||
|
if (result.stockCalculation) {
|
||||||
|
return result.stockCalculation.hasAnyStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback к старой логике для обратной совместимости
|
||||||
|
const hasInternalStock = result.internalOffers?.some((offer: any) =>
|
||||||
|
offer.quantity > 0 && offer.available
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasExternalStock = result.externalOffers?.some((offer: any) =>
|
||||||
|
offer.quantity > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasInternalStock || hasExternalStock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformOffersForCard = (offers: any[], hasStock: boolean = true) => {
|
||||||
return offers.map(offer => {
|
return offers.map(offer => {
|
||||||
const isExternal = offer.type === 'external';
|
const isExternal = offer.type === 'external';
|
||||||
|
const deliveryDays = isExternal ? offer.deliveryTime : offer.deliveryDays;
|
||||||
return {
|
return {
|
||||||
id: offer.id,
|
id: offer.id,
|
||||||
productId: offer.productId,
|
productId: offer.productId,
|
||||||
offerKey: offer.offerKey,
|
offerKey: offer.offerKey,
|
||||||
pcs: `${offer.quantity} шт.`,
|
pcs: `${offer.quantity} шт.`,
|
||||||
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`,
|
days: deliveryDays ? calculateDeliveryDate(deliveryDays) : 'Уточняйте',
|
||||||
recommended: !isExternal && offer.available,
|
recommended: !isExternal && offer.available,
|
||||||
price: `${offer.price.toLocaleString('ru-RU')} ₽`,
|
price: `${offer.price.toLocaleString('ru-RU')} ₽`,
|
||||||
count: "1",
|
count: "1",
|
||||||
@ -191,7 +228,8 @@ const transformOffersForCard = (offers: any[]) => {
|
|||||||
currency: offer.currency || "RUB",
|
currency: offer.currency || "RUB",
|
||||||
warehouse: offer.warehouse,
|
warehouse: offer.warehouse,
|
||||||
supplier: offer.supplier,
|
supplier: offer.supplier,
|
||||||
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays,
|
deliveryTime: deliveryDays,
|
||||||
|
hasStock, // Добавляем информацию о наличии
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -199,8 +237,9 @@ const transformOffersForCard = (offers: any[]) => {
|
|||||||
export default function SearchResult() {
|
export default function SearchResult() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { article, brand, q, artId } = router.query;
|
const { article, brand, q, artId } = router.query;
|
||||||
|
const { state: cartState } = useCart();
|
||||||
|
|
||||||
const [sortActive, setSortActive] = useState(0);
|
// Убрано: глобальная сортировка теперь не используется
|
||||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||||
const [showSortMobile, setShowSortMobile] = useState(false);
|
const [showSortMobile, setShowSortMobile] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
@ -226,10 +265,20 @@ export default function SearchResult() {
|
|||||||
setVisibleAnalogsCount(ANALOGS_CHUNK_SIZE);
|
setVisibleAnalogsCount(ANALOGS_CHUNK_SIZE);
|
||||||
}, [article, brand]);
|
}, [article, brand]);
|
||||||
|
|
||||||
|
// Подготавливаем данные корзины для отправки на backend
|
||||||
|
const cartItems = cartState.items.map(item => ({
|
||||||
|
productId: item.productId,
|
||||||
|
offerKey: item.offerKey,
|
||||||
|
article: item.article || '',
|
||||||
|
brand: item.brand || '',
|
||||||
|
quantity: item.quantity
|
||||||
|
}));
|
||||||
|
|
||||||
const { data, loading, error } = useQuery(SEARCH_PRODUCT_OFFERS, {
|
const { data, loading, error } = useQuery(SEARCH_PRODUCT_OFFERS, {
|
||||||
variables: {
|
variables: {
|
||||||
articleNumber: searchQuery,
|
articleNumber: searchQuery,
|
||||||
brand: brandQuery || '' // Используем пустую строку если бренд не указан
|
brand: brandQuery || '', // Используем пустую строку если бренд не указан
|
||||||
|
cartItems: cartItems
|
||||||
},
|
},
|
||||||
skip: !searchQuery,
|
skip: !searchQuery,
|
||||||
errorPolicy: 'all'
|
errorPolicy: 'all'
|
||||||
@ -542,7 +591,7 @@ export default function SearchResult() {
|
|||||||
<section className="main mobile-only">
|
<section className="main mobile-only">
|
||||||
<div className="w-layout-blockcontainer container w-container">
|
<div className="w-layout-blockcontainer container w-container">
|
||||||
<div className="w-layout-hflex flex-block-84">
|
<div className="w-layout-hflex flex-block-84">
|
||||||
{/* <CatalogSortDropdown active={sortActive} onChange={setSortActive} /> */}
|
{/* Глобальная сортировка убрана - теперь каждый товар сортируется индивидуально */}
|
||||||
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
|
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
|
||||||
<span className="code-embed-9 w-embed">
|
<span className="code-embed-9 w-embed">
|
||||||
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@ -574,7 +623,7 @@ export default function SearchResult() {
|
|||||||
)}
|
)}
|
||||||
{/* Лучшие предложения */}
|
{/* Лучшие предложения */}
|
||||||
{bestOffersData.length > 0 && (
|
{bestOffersData.length > 0 && (
|
||||||
<section className="section-6">
|
<section className="main">
|
||||||
<div className="w-layout-blockcontainer container w-container">
|
<div className="w-layout-blockcontainer container w-container">
|
||||||
<div className="w-layout-vflex flex-block-36">
|
<div className="w-layout-vflex flex-block-36">
|
||||||
{bestOffersData.map(({ offer, type }, index) => (
|
{bestOffersData.map(({ offer, type }, index) => (
|
||||||
@ -584,7 +633,7 @@ export default function SearchResult() {
|
|||||||
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
|
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
|
||||||
description={offer.name}
|
description={offer.name}
|
||||||
price={`${offer.price.toLocaleString()} ₽`}
|
price={`${offer.price.toLocaleString()} ₽`}
|
||||||
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
|
delivery={offer.deliveryDuration ? calculateDeliveryDate(offer.deliveryDuration) : 'Уточняйте'}
|
||||||
stock={`${offer.quantity} шт.`}
|
stock={`${offer.quantity} шт.`}
|
||||||
offer={offer}
|
offer={offer}
|
||||||
/>
|
/>
|
||||||
@ -653,8 +702,10 @@ export default function SearchResult() {
|
|||||||
{/* Основной товар */}
|
{/* Основной товар */}
|
||||||
<div className="w-layout-vflex flex-block-14-copy">
|
<div className="w-layout-vflex flex-block-14-copy">
|
||||||
{hasOffers && result && (() => {
|
{hasOffers && result && (() => {
|
||||||
|
const hasMainProductStock = checkProductStock(result);
|
||||||
const mainProductOffers = transformOffersForCard(
|
const mainProductOffers = transformOffersForCard(
|
||||||
filteredOffers.filter(o => !o.isAnalog)
|
filteredOffers.filter(o => !o.isAnalog),
|
||||||
|
hasMainProductStock
|
||||||
);
|
);
|
||||||
|
|
||||||
// Не показываем основной товар, если у него нет предложений
|
// Не показываем основной товар, если у него нет предложений
|
||||||
@ -675,6 +726,7 @@ export default function SearchResult() {
|
|||||||
offers={mainProductOffers}
|
offers={mainProductOffers}
|
||||||
showMoreText={mainProductOffers.length < filteredOffers.filter(o => !o.isAnalog).length ? "Показать еще" : undefined}
|
showMoreText={mainProductOffers.length < filteredOffers.filter(o => !o.isAnalog).length ? "Показать еще" : undefined}
|
||||||
partsIndexPowered={!!partsIndexImage}
|
partsIndexPowered={!!partsIndexImage}
|
||||||
|
hasStock={hasMainProductStock}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -780,9 +832,11 @@ export default function SearchResult() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return transformOffersForCard(filteredAnalogOffers);
|
return transformOffersForCard(filteredAnalogOffers, checkProductStock(loadedAnalogData));
|
||||||
})() : [];
|
})() : [];
|
||||||
|
|
||||||
|
const hasAnalogStock = loadedAnalogData ? checkProductStock(loadedAnalogData) : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CoreProductCard
|
<CoreProductCard
|
||||||
key={analogKey}
|
key={analogKey}
|
||||||
@ -792,6 +846,7 @@ export default function SearchResult() {
|
|||||||
offers={analogOffers}
|
offers={analogOffers}
|
||||||
isAnalog
|
isAnalog
|
||||||
isLoadingOffers={!loadedAnalogData}
|
isLoadingOffers={!loadedAnalogData}
|
||||||
|
hasStock={hasAnalogStock}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -379,3 +379,40 @@ button,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для состояния "товар в корзине" */
|
||||||
|
.button-icon.in-cart {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon.in-cart::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(156, 163, 175, 0.2);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon.in-cart:hover {
|
||||||
|
opacity: 0.8 !important;
|
||||||
|
background-color: #6b7280 !important;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация для добавления в корзину */
|
||||||
|
.button-icon:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Убеждаемся, что иконка корзины видна в сером состоянии */
|
||||||
|
.button-icon.in-cart .image-11,
|
||||||
|
.button-icon.in-cart svg {
|
||||||
|
filter: brightness(0.7) contrast(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -491,7 +491,6 @@ input#VinSearchInput {
|
|||||||
line-height: 1.4em;
|
line-height: 1.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-9-copy,
|
|
||||||
.text-block-21-copy {
|
.text-block-21-copy {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -499,7 +498,27 @@ input#VinSearchInput {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heading-9-copy {
|
||||||
|
|
||||||
|
text-align: right;
|
||||||
|
margin-left: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.pcs-search {
|
||||||
|
color: var(--_fonts---color--black);
|
||||||
|
font-size: var(--_fonts---font-size--core);
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.heading-9-copy {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.w-layout-hflex.flex-block-6 {
|
.w-layout-hflex.flex-block-6 {
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
}
|
}
|
||||||
@ -919,12 +938,11 @@ a.link-block-2.w-inline-block {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-9-copy {
|
|
||||||
min-width: 100px;
|
|
||||||
|
|
||||||
|
.flex-block-36 {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.flex-block-15-copy {
|
.flex-block-15-copy {
|
||||||
width: 232px!important;
|
width: 232px!important;
|
||||||
min-width: 232px!important;
|
min-width: 232px!important;
|
||||||
@ -1148,3 +1166,110 @@ a.link-block-2.w-inline-block {
|
|||||||
align-items: flex-start !important;
|
align-items: flex-start !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mask.w-slider-mask {
|
||||||
|
height: 100px !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-history-dropdown,
|
||||||
|
.search-results-dropdown,
|
||||||
|
.dropdown-search,
|
||||||
|
.dropdown-list-3.w--open {
|
||||||
|
position: fixed !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
top: 72px !important; /* подберите под ваш header */
|
||||||
|
width: 100vw !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
border-radius: 0 0 16px 16px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(44,62,80,0.10), 0 1.5px 4px rgba(44,62,80,0.08) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricecartbp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px; /* или другой нужный вам отступ */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bestpriceitem {
|
||||||
|
height: 279px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.flex-block-49 {
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcs-search-s1,
|
||||||
|
.sort-item.first {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.pcs-search-s1,
|
||||||
|
.sort-item.first {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.pcs-search-s1,
|
||||||
|
.sort-item.first {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-layout-vflex.flex-block-36 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-layout-vflex.flex-block-44 {
|
||||||
|
flex: 1 1 calc(33.333% - 16px);
|
||||||
|
max-width: calc(33.333% - 16px);
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.w-layout-vflex.flex-block-44 {
|
||||||
|
flex: 1 1 calc(50% - 12px);
|
||||||
|
max-width: calc(50% - 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.w-layout-vflex.flex-block-44 {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.w-layout-vflex.flex-block-36 {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.w-layout-vflex.flex-block-44 {
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 160px;
|
||||||
|
flex: 0 0 160px;
|
||||||
|
}
|
||||||
|
.heading-9-copy {
|
||||||
|
text-align: left !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
@ -66,4 +66,5 @@ export interface CartContextType {
|
|||||||
updateDelivery: (delivery: Partial<DeliveryInfo>) => void
|
updateDelivery: (delivery: Partial<DeliveryInfo>) => void
|
||||||
clearCart: () => void
|
clearCart: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
|
isInCart: (productId?: string, offerKey?: string, article?: string, brand?: string) => boolean
|
||||||
}
|
}
|
@ -5,6 +5,7 @@ FRONTEND_PORT=3000
|
|||||||
NEXT_PUBLIC_CMS_GRAPHQL_URL=https://cms.protekauto.ru/api/graphql
|
NEXT_PUBLIC_CMS_GRAPHQL_URL=https://cms.protekauto.ru/api/graphql
|
||||||
NEXT_PUBLIC_UPLOAD_URL=https://cms.protekauto.ru/upload
|
NEXT_PUBLIC_UPLOAD_URL=https://cms.protekauto.ru/upload
|
||||||
NEXT_PUBLIC_MAINTENANCE_MODE=true
|
NEXT_PUBLIC_MAINTENANCE_MODE=true
|
||||||
|
NEXT_PUBLIC_PARTSAPI_URL=https://api.parts-index.com
|
||||||
|
|
||||||
# Build Configuration
|
# Build Configuration
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
@ -9,7 +9,7 @@ async function testPartsIndexAPI() {
|
|||||||
|
|
||||||
// Получаем каталоги
|
// Получаем каталоги
|
||||||
console.log('\n📦 Получаем список каталогов...');
|
console.log('\n📦 Получаем список каталогов...');
|
||||||
const catalogsResponse = await fetch('https://api.parts-index.com/v1/catalogs?lang=ru', {
|
const catalogsResponse = await fetch(process.env.PARTSAPI_URL+"/v1/catalogs?lang=ru", {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
@ -31,7 +31,7 @@ async function testPartsIndexAPI() {
|
|||||||
console.log(`\n🎯 Получаем группы для каталога "${firstCatalog.name}"...`);
|
console.log(`\n🎯 Получаем группы для каталога "${firstCatalog.name}"...`);
|
||||||
|
|
||||||
const groupsResponse = await fetch(
|
const groupsResponse = await fetch(
|
||||||
`https://api.parts-index.com/v1/catalogs/${firstCatalog.id}/groups?lang=ru`,
|
`${process.env.PARTSAPI_URL}/v1/catalogs/${firstCatalog.id}/groups?lang=ru`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
21
user_input.py
Normal file
21
user_input.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
print("\n" + "="*50)
|
||||||
|
user_input = input("Please provide feedback or next task (type 'stop' to exit): ").strip()
|
||||||
|
|
||||||
|
if user_input.lower() == 'stop':
|
||||||
|
print("Exiting task loop. Thank you!")
|
||||||
|
break
|
||||||
|
elif user_input.lower() == '':
|
||||||
|
print("Please provide some input or type 'stop' to exit.")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print(f"\nReceived input: {user_input}")
|
||||||
|
print("Processing your request...")
|
||||||
|
# Here the main process would handle the user's input
|
||||||
|
return user_input
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = main()
|
||||||
|
if result and result.lower() != 'stop':
|
||||||
|
print(f"Next task received: {result}")
|
Reference in New Issue
Block a user