first commit
This commit is contained in:
86
src/pages/_app.tsx
Normal file
86
src/pages/_app.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/normalize.css";
|
||||
import "@/styles/webflow.css";
|
||||
import "@/styles/protekproject.webflow.css";
|
||||
import "@/styles/my.css";
|
||||
import "@/styles/maintenance.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import Script from "next/script";
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
import { apolloClient } from '@/lib/apollo';
|
||||
import React, { useState } from "react";
|
||||
import MaintenanceMode from '@/components/MaintenanceMode';
|
||||
import { CartProvider } from '@/contexts/CartContext';
|
||||
import { FavoritesProvider } from '@/contexts/FavoritesContext';
|
||||
import Layout from "@/components/Layout";
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const maintenanceMode = process.env.NEXT_PUBLIC_MAINTENANCE_MODE === 'true';
|
||||
const savedAuth = typeof window !== 'undefined' ? localStorage.getItem('maintenance_authenticated') : null;
|
||||
setIsMaintenanceMode(maintenanceMode);
|
||||
setIsAuthenticated(savedAuth === 'true');
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handlePasswordCorrect = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('maintenance_authenticated', 'true');
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMaintenanceMode && !isAuthenticated) {
|
||||
return <MaintenanceMode onPasswordCorrect={handlePasswordCorrect} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<FavoritesProvider>
|
||||
<CartProvider>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#4ade80',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Script src="/js/webflow.js" strategy="beforeInteractive" />
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</CartProvider>
|
||||
</FavoritesProvider>
|
||||
</ApolloProvider>
|
||||
);
|
||||
}
|
13
src/pages/_document.tsx
Normal file
13
src/pages/_document.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
56
src/pages/about.tsx
Normal file
56
src/pages/about.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import Head from "next/head";
|
||||
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import CatalogInfoHeader from "@/components/CatalogInfoHeader";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import AboutIntro from "@/components/about/AboutIntro";
|
||||
import AboutOffers from "@/components/about/AboutOffers";
|
||||
import AboutProtekInfo from "@/components/about/AboutProtekInfo";
|
||||
import AboutHelp from "@/components/about/AboutHelp";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>About</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<CatalogInfoHeader
|
||||
title="О компании"
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "О компании" }
|
||||
]}
|
||||
/>
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<AboutIntro />
|
||||
<AboutOffers />
|
||||
<AboutProtekInfo />
|
||||
<AboutHelp />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
<style jsx>{`
|
||||
.text-block-36 { font-size: 14px; }
|
||||
.submit-button.w-button { font-size: 16px; }
|
||||
.heading-14 { font-size: 20px; }
|
||||
.heading-13 { font-size: 24px; }
|
||||
.text-block-37 { font-size: 14px; }
|
||||
.text-block-38 { font-size: 14px; }
|
||||
.text-block-19 { font-size: 16px; }
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
16
src/pages/api/debug-env.ts
Normal file
16
src/pages/api/debug-env.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Проверяем только публичные переменные для безопасности
|
||||
const envVars = {
|
||||
NEXT_PUBLIC_MAINTENANCE_MODE: process.env.NEXT_PUBLIC_MAINTENANCE_MODE,
|
||||
NEXT_PUBLIC_CMS_GRAPHQL_URL: process.env.NEXT_PUBLIC_CMS_GRAPHQL_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
};
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Environment Variables Debug',
|
||||
env: envVars,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
13
src/pages/api/hello.ts
Normal file
13
src/pages/api/hello.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Data = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>,
|
||||
) {
|
||||
res.status(200).json({ name: "John Doe" });
|
||||
}
|
142
src/pages/article-search.tsx
Normal file
142
src/pages/article-search.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import { DOC_FIND_OEM } from "@/lib/graphql";
|
||||
import { LaximoDocFindOEMResult } from "@/types/laximo";
|
||||
|
||||
const InfoArticleSearch = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const ArticleSearchPage = () => {
|
||||
const router = useRouter();
|
||||
const { article } = router.query;
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (article && typeof article === 'string') {
|
||||
setSearchQuery(article.trim().toUpperCase());
|
||||
}
|
||||
}, [article]);
|
||||
|
||||
const { data, loading, error } = useQuery(DOC_FIND_OEM, {
|
||||
variables: {
|
||||
oemNumber: searchQuery
|
||||
},
|
||||
skip: !searchQuery,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const handleFindOffers = (articleNumber: string, brand: string) => {
|
||||
router.push(`/search-result?article=${encodeURIComponent(articleNumber)}&brand=${encodeURIComponent(brand)}`);
|
||||
};
|
||||
|
||||
const result: LaximoDocFindOEMResult | null = data?.laximoDocFindOEM || null;
|
||||
const hasResults = result && result.details && result.details.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск деталей по артикулу {searchQuery} - Protek</title>
|
||||
<meta name="description" content={`Результаты поиска деталей по артикулу ${searchQuery}`} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoArticleSearch />
|
||||
<div className="page-wrapper bg-[#F5F8FB] min-h-screen">
|
||||
<div className="flex flex-col px-32 pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="w-full max-w-[1200px]">
|
||||
{loading && (
|
||||
<div className="bg-white rounded-2xl shadow p-10 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-red-600 mb-6"></div>
|
||||
<p className="text-lg text-gray-600">Поиск деталей по артикулу...</p>
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl shadow p-10 mb-6">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-6 h-6 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-red-800">Ошибка поиска</h3>
|
||||
<p className="text-red-700 mt-1">Произошла ошибка при поиске деталей. Попробуйте еще раз.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasResults && !loading && !error && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-2xl shadow p-10 text-center">
|
||||
<svg className="w-16 h-16 text-yellow-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.29-1.009-5.824-2.562M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-yellow-800 mb-2">Детали не найдены</h3>
|
||||
<p className="text-yellow-700 mb-4">
|
||||
По артикулу <span className="font-mono font-semibold">{searchQuery}</span> детали не найдены.
|
||||
</p>
|
||||
<p className="text-sm text-yellow-600">
|
||||
Попробуйте изменить запрос или проверьте правильность написания артикула.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasResults && (
|
||||
<div className="bg-white rounded-2xl shadow p-10">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Поиск деталей по артикулу: {searchQuery}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Выберите нужную деталь
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{result.details.map((detail, index) => (
|
||||
<div key={detail.detailid || index}>
|
||||
<button
|
||||
onClick={() => handleFindOffers(detail.formattedoem || detail.oem, detail.manufacturer)}
|
||||
className="w-full text-left p-4 hover:bg-gray-50 transition-colors block"
|
||||
>
|
||||
<div className="self-stretch my-auto font-bold leading-snug text-gray-950 max-md:w-full hover:text-[#EC1C24] transition-colors">
|
||||
{detail.manufacturer}: {detail.formattedoem || detail.oem} {detail.name}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleSearchPage;
|
118
src/pages/brands.tsx
Normal file
118
src/pages/brands.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import { GET_LAXIMO_BRANDS } from "@/lib/graphql";
|
||||
import { LaximoBrand } from "@/types/laximo";
|
||||
import BrandWizardSearchSection from "@/components/BrandWizardSearchSection";
|
||||
|
||||
const InfoBrands = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const BrandsPage = () => {
|
||||
const router = useRouter();
|
||||
const [selectedLetter, setSelectedLetter] = useState<string>('');
|
||||
const { data, loading, error } = useQuery<{ laximoBrands: LaximoBrand[] }>(GET_LAXIMO_BRANDS, { errorPolicy: 'all' });
|
||||
|
||||
const staticBrands = [
|
||||
{ name: "Audi", code: "audi" },
|
||||
{ name: "BMW", code: "bmw" },
|
||||
{ name: "Cadillac", code: "cadillac" },
|
||||
{ name: "Chevrolet", code: "chevrolet" },
|
||||
{ name: "Citroen", code: "citroen" },
|
||||
{ name: "Fiat", code: "fiat" },
|
||||
{ name: "Mazda", code: "mazda" },
|
||||
{ name: "Mercedes-Benz", code: "mercedes" },
|
||||
{ name: "Nissan", code: "nissan" },
|
||||
{ name: "Opel", code: "opel" },
|
||||
{ name: "Peugeot", code: "peugeot" },
|
||||
{ name: "Renault", code: "renault" },
|
||||
{ name: "Toyota", code: "toyota" },
|
||||
{ name: "Volkswagen", code: "volkswagen" },
|
||||
{ name: "Volvo", code: "volvo" }
|
||||
];
|
||||
|
||||
let brands = staticBrands;
|
||||
if (data?.laximoBrands && data.laximoBrands.length > 0) {
|
||||
brands = data.laximoBrands.map(brand => ({ name: brand.name, code: brand.code }));
|
||||
} else if (error) {
|
||||
console.warn('Laximo API недоступен, используются статические данные:', error.message);
|
||||
}
|
||||
|
||||
const brandsByLetter = useMemo(() => {
|
||||
const grouped: { [key: string]: typeof brands } = {};
|
||||
brands.forEach(brand => {
|
||||
const firstLetter = brand.name.charAt(0).toUpperCase();
|
||||
if (!grouped[firstLetter]) grouped[firstLetter] = [];
|
||||
grouped[firstLetter].push(brand);
|
||||
});
|
||||
Object.keys(grouped).forEach(letter => {
|
||||
grouped[letter].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
return grouped;
|
||||
}, [brands]);
|
||||
|
||||
const letters = Object.keys(brandsByLetter).sort();
|
||||
|
||||
const handleBrandClick = (brand: { name: string; code?: string }) => {
|
||||
if (brand.code) router.push(`/brands?selected=${brand.code}`);
|
||||
};
|
||||
|
||||
const handleLetterClick = (letter: string) => {
|
||||
setSelectedLetter(selectedLetter === letter ? '' : letter);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Все марки автомобилей - Protek</title>
|
||||
<meta name="description" content="Полный каталог автомобильных брендов для поиска запчастей" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoBrands />
|
||||
<BrandWizardSearchSection />
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BrandGrid = ({ brands, onBrandClick }: { brands: { name: string; code?: string }[]; onBrandClick: (brand: { name: string; code?: string }) => void }) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{brands.map((brand, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onBrandClick(brand)}
|
||||
className="bg-[#F5F8FB] hover:bg-white border border-gray-200 hover:border-[#EC1C24] rounded-xl p-4 text-left font-medium text-[#000814] shadow-sm transition-all duration-200"
|
||||
>
|
||||
{brand.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BrandsPage;
|
277
src/pages/card.tsx
Normal file
277
src/pages/card.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useQuery, useLazyQuery } from "@apollo/client";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Footer from "@/components/Footer";
|
||||
import Header from "@/components/Header";
|
||||
import CartRecommended from "@/components/CartRecommended";
|
||||
import InfoCard from "@/components/card/InfoCard";
|
||||
import ProductImageGallery from "@/components/card/ProductImageGallery";
|
||||
import ProductSortHeader from "@/components/card/ProductSortHeader";
|
||||
import ProductList from "@/components/card/ProductList";
|
||||
import ShowMoreOffers from "@/components/card/ShowMoreOffers";
|
||||
import ProductCharacteristics from "@/components/card/ProductCharacteristics";
|
||||
import ProductDescriptionTabs from "@/components/card/ProductDescriptionTabs";
|
||||
import { SEARCH_PRODUCT_OFFERS, PARTS_INDEX_SEARCH_BY_ARTICLE, GET_ANALOG_OFFERS } from "@/lib/graphql";
|
||||
import { useArticleImage } from "@/hooks/useArticleImage";
|
||||
import { useRecommendedProducts } from "../hooks/useRecommendedProducts";
|
||||
|
||||
const INITIAL_OFFERS_COUNT = 4;
|
||||
|
||||
export default function CardPage() {
|
||||
const router = useRouter();
|
||||
const { article, brand, q, artId } = router.query;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [brandQuery, setBrandQuery] = useState<string>("");
|
||||
const [sortBy, setSortBy] = useState<string>("price"); // price, quantity, delivery
|
||||
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_COUNT);
|
||||
const [analogsWithOffers, setAnalogsWithOffers] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (article && typeof article === 'string') {
|
||||
setSearchQuery(article.trim().toUpperCase());
|
||||
}
|
||||
if (brand && typeof brand === 'string') {
|
||||
setBrandQuery(brand.trim());
|
||||
}
|
||||
setVisibleOffersCount(INITIAL_OFFERS_COUNT);
|
||||
}, [article, brand]);
|
||||
|
||||
const { data, loading, error } = useQuery(SEARCH_PRODUCT_OFFERS, {
|
||||
variables: {
|
||||
articleNumber: searchQuery,
|
||||
brand: brandQuery || ''
|
||||
},
|
||||
skip: !searchQuery,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
// УБИРАЕМ ЗАПРОС К PARTSINDEX ДЛЯ ОПТИМИЗАЦИИ
|
||||
// Теперь данные PartsIndex будут получаться только по требованию
|
||||
// const { data: partsIndexData, loading: partsIndexLoading } = useQuery(PARTS_INDEX_SEARCH_BY_ARTICLE, {
|
||||
// variables: {
|
||||
// articleNumber: searchQuery,
|
||||
// brandName: brandQuery || '',
|
||||
// lang: 'ru'
|
||||
// },
|
||||
// skip: !searchQuery || !brandQuery,
|
||||
// errorPolicy: 'ignore'
|
||||
// });
|
||||
|
||||
const { imageUrl: mainImageUrl } = useArticleImage(artId as string, { enabled: !!artId });
|
||||
|
||||
const result = data?.searchProductOffers;
|
||||
|
||||
// Запрос для получения предложений аналогов
|
||||
const [getAnalogOffers, { loading: analogsLoading }] = useLazyQuery(GET_ANALOG_OFFERS, {
|
||||
onCompleted: (analogsData) => {
|
||||
if (analogsData?.getAnalogOffers) {
|
||||
setAnalogsWithOffers(analogsData.getAnalogOffers);
|
||||
}
|
||||
},
|
||||
errorPolicy: 'ignore'
|
||||
});
|
||||
|
||||
// Автоматически загружаем предложения для аналогов
|
||||
useEffect(() => {
|
||||
if (result?.analogs && result.analogs.length > 0) {
|
||||
const analogsToLoad = result.analogs.slice(0, 5).map((analog: any) => ({
|
||||
articleNumber: analog.articleNumber,
|
||||
brand: analog.brand,
|
||||
name: analog.name,
|
||||
type: analog.type
|
||||
}));
|
||||
|
||||
getAnalogOffers({ variables: { analogs: analogsToLoad } });
|
||||
}
|
||||
}, [result?.analogs, getAnalogOffers]);
|
||||
|
||||
// Рекомендуемые товары из той же категории с AutoEuro предложениями
|
||||
const { recommendedProducts, isLoading: isLoadingRecommendedPrices } = useRecommendedProducts(
|
||||
result?.name || '',
|
||||
result?.articleNumber || searchQuery || '',
|
||||
result?.brand || brandQuery || ''
|
||||
);
|
||||
|
||||
// Если это поиск по параметру q, используем q как article
|
||||
useEffect(() => {
|
||||
if (q && typeof q === 'string' && !article) {
|
||||
setSearchQuery(q.trim().toUpperCase());
|
||||
const catalogFromUrl = router.query.catalog as string;
|
||||
|
||||
if (catalogFromUrl) {
|
||||
const catalogToBrandMap: { [key: string]: string } = {
|
||||
'AU1587': 'AUDI',
|
||||
'VW1587': 'VOLKSWAGEN',
|
||||
'BMW1587': 'BMW',
|
||||
'MB1587': 'MERCEDES-BENZ',
|
||||
};
|
||||
|
||||
setBrandQuery(catalogToBrandMap[catalogFromUrl] || '');
|
||||
} else {
|
||||
setBrandQuery('');
|
||||
}
|
||||
}
|
||||
}, [q, article, router.query]);
|
||||
|
||||
// Собираем и сортируем все предложения (БЕЗ аналогов)
|
||||
const allOffers = useMemo(() => {
|
||||
if (!result) return [];
|
||||
|
||||
const offers: any[] = [];
|
||||
|
||||
// Добавляем только предложения основного товара (НЕ аналоги)
|
||||
if (result.internalOffers) {
|
||||
result.internalOffers.forEach((offer: any) => {
|
||||
// Показываем только предложения с ценой больше 0
|
||||
if (offer.price && offer.price > 0) {
|
||||
offers.push({
|
||||
...offer,
|
||||
type: 'internal',
|
||||
brand: result.brand,
|
||||
articleNumber: result.articleNumber,
|
||||
name: result.name,
|
||||
isAnalog: false,
|
||||
deliveryTime: offer.deliveryDays,
|
||||
sortPrice: offer.price
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (result.externalOffers) {
|
||||
result.externalOffers.forEach((offer: any) => {
|
||||
// Показываем только предложения с ценой больше 0
|
||||
if (offer.price && offer.price > 0) {
|
||||
offers.push({
|
||||
...offer,
|
||||
type: 'external',
|
||||
brand: offer.brand || result.brand,
|
||||
articleNumber: offer.code || result.articleNumber,
|
||||
name: offer.name || result.name,
|
||||
isAnalog: false,
|
||||
deliveryTime: offer.deliveryTime,
|
||||
sortPrice: offer.price
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Сортировка предложений
|
||||
const sortedOffers = [...offers].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'price':
|
||||
return a.sortPrice - b.sortPrice;
|
||||
case 'quantity':
|
||||
return (b.quantity || 0) - (a.quantity || 0);
|
||||
case 'delivery':
|
||||
return (a.deliveryTime || 999) - (b.deliveryTime || 999);
|
||||
default:
|
||||
return a.sortPrice - b.sortPrice;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedOffers;
|
||||
}, [result, sortBy]);
|
||||
|
||||
// Видимые предложения
|
||||
const visibleOffers = allOffers.slice(0, visibleOffersCount);
|
||||
const hasMoreOffers = allOffers.length > visibleOffersCount;
|
||||
|
||||
const handleShowMoreOffers = () => {
|
||||
setVisibleOffersCount(prev => Math.min(prev + 4, allOffers.length));
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
setSortBy(newSortBy);
|
||||
setVisibleOffersCount(INITIAL_OFFERS_COUNT); // Сбрасываем к начальному количеству
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Загрузка товара - Protek</title>
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">Загрузка данных товара...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{result ? `${result.brand} ${result.articleNumber} - ${result.name}` : `Карточка товара`} - Protek</title>
|
||||
<meta name="description" content={`Подробная информация о товаре ${result?.name}`} />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoCard
|
||||
brand={result ? result.brand : brandQuery}
|
||||
articleNumber={result ? result.articleNumber : searchQuery}
|
||||
name={result ? result.name : "деталь"}
|
||||
productId={artId ? String(artId) : undefined}
|
||||
price={allOffers.length > 0 ? Math.min(...allOffers.map(offer => offer.sortPrice)) : 0}
|
||||
currency="RUB"
|
||||
image={mainImageUrl || (result?.partsIndexImages && result.partsIndexImages.length > 0 ? result.partsIndexImages[0].url : undefined)}
|
||||
/>
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex flex-block-14">
|
||||
<div className="w-layout-hflex core-product-card-copy">
|
||||
<ProductImageGallery
|
||||
imageUrl={mainImageUrl}
|
||||
partsIndexImages={result?.partsIndexImages?.map((img: any) => img.url) || []}
|
||||
/>
|
||||
<div className="w-layout-vflex flex-block-48">
|
||||
<ProductSortHeader
|
||||
brand={result ? result.brand : brandQuery}
|
||||
articleNumber={result ? result.articleNumber : searchQuery}
|
||||
name={result ? result.name : "деталь"}
|
||||
sortBy={sortBy}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
<ProductList
|
||||
offers={visibleOffers}
|
||||
isLoading={loading}
|
||||
/>
|
||||
<ShowMoreOffers
|
||||
hasMoreOffers={hasMoreOffers}
|
||||
onShowMore={handleShowMoreOffers}
|
||||
remainingCount={allOffers.length - visibleOffersCount}
|
||||
/>
|
||||
<div className="w-layout-vflex description-item">
|
||||
<ProductCharacteristics
|
||||
result={result}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CartRecommended
|
||||
recommendedProducts={recommendedProducts}
|
||||
isLoadingPrices={analogsLoading || isLoadingRecommendedPrices}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
43
src/pages/cart-step-2.tsx
Normal file
43
src/pages/cart-step-2.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import CartRecommended from "../components/CartRecommended";
|
||||
import CartInfo from "../components/CartInfo";
|
||||
import CartList2 from "../components/CartList2";
|
||||
import CartSummary2 from "../components/CartSummary2";
|
||||
import MobileMenuBottomSection from "../components/MobileMenuBottomSection";
|
||||
|
||||
export default function CartStep2() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Cart Step 2</title>
|
||||
<meta name="description" content="Cart Step 2" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<CartInfo />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex cart-list">
|
||||
<div className="w-layout-hflex core-product-card">
|
||||
<CartList2 />
|
||||
<CartSummary2 />
|
||||
</div>
|
||||
<CartRecommended />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
45
src/pages/cart.tsx
Normal file
45
src/pages/cart.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import Header from "@/components/Header";
|
||||
import Head from "next/head";
|
||||
import Footer from "@/components/Footer";
|
||||
import CartInfo from "@/components/CartInfo";
|
||||
import CartList from "@/components/CartList";
|
||||
import CartSummary from "@/components/CartSummary";
|
||||
import CartRecommended from "../components/CartRecommended";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import CartDebug from "@/components/CartDebug";
|
||||
|
||||
export default function CartPage() {
|
||||
|
||||
return (
|
||||
<><Head>
|
||||
<title>Cart</title>
|
||||
<meta name="description" content="Cart" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<CartInfo />
|
||||
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex cart-list">
|
||||
<div className="w-layout-hflex core-product-card">
|
||||
<CartList />
|
||||
<CartSummary />
|
||||
</div>
|
||||
<CartRecommended />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
<CartDebug />
|
||||
</>
|
||||
);
|
||||
}
|
850
src/pages/catalog.tsx
Normal file
850
src/pages/catalog.tsx
Normal file
@ -0,0 +1,850 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import ProductListCard from "@/components/ProductListCard";
|
||||
import Filters, { FilterConfig } from "@/components/Filters";
|
||||
import FiltersWithSearch from "@/components/FiltersWithSearch";
|
||||
import CatalogProductCard from "@/components/CatalogProductCard";
|
||||
import CatalogPagination from "@/components/CatalogPagination";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import CatalogInfoHeader from "@/components/CatalogInfoHeader";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import FiltersPanelMobile from '@/components/FiltersPanelMobile';
|
||||
import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
|
||||
import { GET_PARTSAPI_ARTICLES, GET_PARTSAPI_MAIN_IMAGE, SEARCH_PRODUCT_OFFERS, GET_PARTSINDEX_CATALOG_ENTITIES, GET_PARTSINDEX_CATALOG_PARAMS } from '@/lib/graphql';
|
||||
import { PartsAPIArticlesData, PartsAPIArticlesVariables, PartsAPIArticle, PartsAPIMainImageData, PartsAPIMainImageVariables } from '@/types/partsapi';
|
||||
import { PartsIndexEntitiesData, PartsIndexEntitiesVariables, PartsIndexEntity, PartsIndexParamsData, PartsIndexParamsVariables } from '@/types/partsindex';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import CatalogEmptyState from '@/components/CatalogEmptyState';
|
||||
import { useProductPrices } from '@/hooks/useProductPrices';
|
||||
import { PriceSkeleton } from '@/components/skeletons/ProductListSkeleton';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const mockData = Array(12).fill({
|
||||
image: "",
|
||||
discount: "-35%",
|
||||
price: "от 17 087 ₽",
|
||||
oldPrice: "22 347 ₽",
|
||||
title: 'Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60',
|
||||
brand: "Borsehung",
|
||||
});
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
|
||||
|
||||
export default function Catalog() {
|
||||
const router = useRouter();
|
||||
const { addItem } = useCart();
|
||||
const {
|
||||
partsApiCategory: strId,
|
||||
categoryName,
|
||||
partsIndexCatalog: catalogId,
|
||||
partsIndexCategory: groupId
|
||||
} = router.query;
|
||||
|
||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||
const [showSortMobile, setShowSortMobile] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({});
|
||||
const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]);
|
||||
const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [entitiesPage, setEntitiesPage] = useState(1); // Страница для PartsIndex
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMoreEntities, setHasMoreEntities] = useState(true); // Есть ли еще товары на сервере
|
||||
const [showAllBrands, setShowAllBrands] = useState(false);
|
||||
const [catalogFilters, setCatalogFilters] = useState<FilterConfig[]>([]);
|
||||
const [filtersLoading, setFiltersLoading] = useState(true);
|
||||
const [sortActive, setSortActive] = useState(0);
|
||||
const [visibleProductsCount, setVisibleProductsCount] = useState(0); // Счетчик товаров с предложениями
|
||||
const [filtersGenerating, setFiltersGenerating] = useState(false); // Состояние генерации фильтров
|
||||
const [targetVisibleCount, setTargetVisibleCount] = useState(ITEMS_PER_PAGE); // Целевое количество видимых товаров
|
||||
const [loadedArticlesCount, setLoadedArticlesCount] = useState(ITEMS_PER_PAGE); // Количество загруженных артикулов
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
|
||||
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц
|
||||
|
||||
// Карта видимости товаров по индексу
|
||||
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
|
||||
|
||||
// Обработчик изменения видимости товара
|
||||
const handleVisibilityChange = useCallback((index: number, isVisible: boolean) => {
|
||||
setVisibilityMap(prev => {
|
||||
const currentVisibility = prev.get(index);
|
||||
// Обновляем только если значение действительно изменилось
|
||||
if (currentVisibility === isVisible) {
|
||||
return prev;
|
||||
}
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(index, isVisible);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Пересчитываем количество видимых товаров при изменении карты видимости
|
||||
useEffect(() => {
|
||||
const visibleCount = Array.from(visibilityMap.values()).filter(Boolean).length;
|
||||
setVisibleProductsCount(visibleCount);
|
||||
}, [visibilityMap]);
|
||||
|
||||
// Определяем режим работы
|
||||
const isPartsAPIMode = Boolean(strId && categoryName);
|
||||
const isPartsIndexMode = Boolean(catalogId && categoryName && groupId); // Требуем groupId для PartsIndex
|
||||
const isPartsIndexCatalogOnly = Boolean(catalogId && categoryName && !groupId); // Каталог без группы
|
||||
|
||||
// Отладочная информация
|
||||
console.log('🔍 Режимы работы каталога:', {
|
||||
catalogId,
|
||||
groupId,
|
||||
categoryName,
|
||||
isPartsAPIMode,
|
||||
isPartsIndexMode,
|
||||
isPartsIndexCatalogOnly
|
||||
});
|
||||
|
||||
// Загружаем артикулы PartsAPI
|
||||
const { data: articlesData, loading: articlesLoading, error: articlesError } = useQuery<PartsAPIArticlesData, PartsAPIArticlesVariables>(
|
||||
GET_PARTSAPI_ARTICLES,
|
||||
{
|
||||
variables: {
|
||||
strId: parseInt(strId as string),
|
||||
carId: 9877,
|
||||
carType: 'PC'
|
||||
},
|
||||
skip: !isPartsAPIMode,
|
||||
fetchPolicy: 'cache-first'
|
||||
}
|
||||
);
|
||||
|
||||
const allArticles = articlesData?.partsAPIArticles || [];
|
||||
|
||||
// Загружаем товары PartsIndex
|
||||
const { data: entitiesData, loading: entitiesLoading, error: entitiesError, refetch: refetchEntities } = useQuery<PartsIndexEntitiesData, PartsIndexEntitiesVariables>(
|
||||
GET_PARTSINDEX_CATALOG_ENTITIES,
|
||||
{
|
||||
variables: {
|
||||
catalogId: catalogId as string,
|
||||
groupId: groupId as string,
|
||||
lang: 'ru',
|
||||
limit: ITEMS_PER_PAGE,
|
||||
page: partsIndexPage,
|
||||
q: searchQuery || undefined,
|
||||
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined
|
||||
},
|
||||
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
|
||||
fetchPolicy: 'cache-and-network'
|
||||
}
|
||||
);
|
||||
|
||||
// Загружаем параметры фильтрации для PartsIndex
|
||||
const { data: paramsData, loading: paramsLoading, error: paramsError } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>(
|
||||
GET_PARTSINDEX_CATALOG_PARAMS,
|
||||
{
|
||||
variables: {
|
||||
catalogId: catalogId as string,
|
||||
groupId: groupId as string,
|
||||
lang: 'ru',
|
||||
q: searchQuery || undefined,
|
||||
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined
|
||||
},
|
||||
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
|
||||
fetchPolicy: 'cache-first'
|
||||
}
|
||||
);
|
||||
|
||||
// allEntities больше не используется - используем allLoadedEntities
|
||||
|
||||
// Хук для загрузки цен товаров PartsIndex
|
||||
const productsForPrices = visibleEntities.map(entity => ({
|
||||
id: entity.id,
|
||||
code: entity.code,
|
||||
brand: entity.brand.name
|
||||
}));
|
||||
const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices);
|
||||
|
||||
useEffect(() => {
|
||||
if (articlesData?.partsAPIArticles) {
|
||||
// Загружаем изначально только ITEMS_PER_PAGE товаров
|
||||
const initialLoadCount = Math.min(ITEMS_PER_PAGE, articlesData.partsAPIArticles.length);
|
||||
setVisibleArticles(articlesData.partsAPIArticles.slice(0, initialLoadCount));
|
||||
setLoadedArticlesCount(initialLoadCount);
|
||||
setTargetVisibleCount(ITEMS_PER_PAGE);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [articlesData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entitiesData?.partsIndexCatalogEntities?.list) {
|
||||
console.log('📊 Обновляем entitiesData:', {
|
||||
listLength: entitiesData.partsIndexCatalogEntities.list.length,
|
||||
pagination: entitiesData.partsIndexCatalogEntities.pagination,
|
||||
currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1
|
||||
});
|
||||
|
||||
const newEntities = entitiesData.partsIndexCatalogEntities.list;
|
||||
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
|
||||
|
||||
// Обновляем список товаров
|
||||
setVisibleEntities(newEntities);
|
||||
|
||||
// Обновляем информацию о пагинации
|
||||
const currentPage = pagination?.page?.current || 1;
|
||||
const hasNext = pagination?.page?.next !== null;
|
||||
const hasPrev = pagination?.page?.prev !== null;
|
||||
|
||||
setPartsIndexPage(currentPage);
|
||||
setHasMoreEntities(hasNext);
|
||||
|
||||
// Вычисляем общее количество страниц (приблизительно)
|
||||
if (hasNext) {
|
||||
setTotalPages(currentPage + 1); // Минимум еще одна страница
|
||||
} else {
|
||||
setTotalPages(currentPage); // Это последняя страница
|
||||
}
|
||||
|
||||
console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev });
|
||||
}
|
||||
}, [entitiesData]);
|
||||
|
||||
// Генерация фильтров для PartsIndex на основе параметров API
|
||||
const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
|
||||
if (!paramsData?.partsIndexCatalogParams?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return paramsData.partsIndexCatalogParams.list.map(param => {
|
||||
if (param.type === 'range') {
|
||||
// Для range фильтров ищем min и max значения
|
||||
const numericValues = param.values
|
||||
.map(v => parseFloat(v.value))
|
||||
.filter(v => !isNaN(v));
|
||||
|
||||
const min = numericValues.length > 0 ? Math.min(...numericValues) : 0;
|
||||
const max = numericValues.length > 0 ? Math.max(...numericValues) : 100;
|
||||
|
||||
return {
|
||||
type: 'range' as const,
|
||||
title: param.name,
|
||||
min,
|
||||
max
|
||||
};
|
||||
} else {
|
||||
// Для dropdown фильтров
|
||||
return {
|
||||
type: 'dropdown' as const,
|
||||
title: param.name,
|
||||
options: param.values
|
||||
.filter(value => value.available) // Показываем только доступные
|
||||
.map(value => value.title || value.value),
|
||||
multi: true,
|
||||
showAll: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}, [paramsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPartsIndexMode) {
|
||||
// Для PartsIndex генерируем фильтры на основе параметров API
|
||||
const filters = generatePartsIndexFilters();
|
||||
setCatalogFilters(filters);
|
||||
setFiltersLoading(paramsLoading);
|
||||
} else {
|
||||
// Для других режимов убираем запрос на catalog-filters
|
||||
setFiltersLoading(false);
|
||||
}
|
||||
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]);
|
||||
|
||||
// Генерируем динамические фильтры для PartsAPI
|
||||
const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
|
||||
if (!allArticles.length) return [];
|
||||
|
||||
// Получаем список видимых товаров из карты видимости
|
||||
const visibleIndices = Array.from(visibilityMap.entries())
|
||||
.filter(([_, isVisible]) => isVisible)
|
||||
.map(([index]) => index);
|
||||
|
||||
// Если еще нет данных о видимости, используем все товары (для начальной загрузки)
|
||||
const articlesToProcess = visibilityMap.size === 0 ? allArticles :
|
||||
visibleIndices.map(index => allArticles[index]).filter(Boolean);
|
||||
|
||||
const brandCounts = new Map<string, number>();
|
||||
const productGroups = new Set<string>();
|
||||
|
||||
// Подсчитываем количество товаров для каждого бренда (только видимые)
|
||||
articlesToProcess.forEach(article => {
|
||||
if (article?.artSupBrand) {
|
||||
brandCounts.set(article.artSupBrand, (brandCounts.get(article.artSupBrand) || 0) + 1);
|
||||
}
|
||||
if (article?.productGroup) productGroups.add(article.productGroup);
|
||||
});
|
||||
|
||||
const filters: FilterConfig[] = [];
|
||||
|
||||
if (brandCounts.size > 1) {
|
||||
// Сортируем бренды по количеству товаров (по убыванию)
|
||||
const sortedBrands = Array.from(brandCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1]) // Сортируем по количеству товаров
|
||||
.map(([brand]) => brand);
|
||||
|
||||
// Показываем либо первые N брендов, либо все (если нажата кнопка "Показать еще")
|
||||
const brandsToShow = showAllBrands ? sortedBrands : sortedBrands.slice(0, MAX_BRANDS_DISPLAY);
|
||||
|
||||
filters.push({
|
||||
type: "dropdown",
|
||||
title: "Бренд",
|
||||
options: brandsToShow.sort(), // Сортируем по алфавиту для удобства
|
||||
multi: true,
|
||||
showAll: true,
|
||||
defaultOpen: true,
|
||||
hasMore: !showAllBrands && sortedBrands.length > MAX_BRANDS_DISPLAY,
|
||||
onShowMore: () => setShowAllBrands(true)
|
||||
});
|
||||
}
|
||||
|
||||
if (productGroups.size > 1) {
|
||||
filters.push({
|
||||
type: "dropdown",
|
||||
title: "Группа товаров",
|
||||
options: Array.from(productGroups).sort(),
|
||||
multi: true,
|
||||
showAll: true,
|
||||
defaultOpen: true,
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [allArticles, showAllBrands, visibilityMap]);
|
||||
|
||||
const dynamicFilters = useMemo(() => {
|
||||
if (isPartsIndexMode) {
|
||||
return generatePartsIndexFilters();
|
||||
} else if (isPartsAPIMode) {
|
||||
return generatePartsAPIFilters();
|
||||
}
|
||||
return [];
|
||||
}, [isPartsIndexMode, isPartsAPIMode, generatePartsIndexFilters, generatePartsAPIFilters]);
|
||||
|
||||
// Отдельный useEffect для управления состоянием загрузки фильтров
|
||||
useEffect(() => {
|
||||
if ((isPartsAPIMode && allArticles.length > 0) || (isPartsIndexMode && visibleEntities.length > 0)) {
|
||||
setFiltersGenerating(true);
|
||||
const timer = setTimeout(() => {
|
||||
setFiltersGenerating(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setFiltersGenerating(false);
|
||||
}
|
||||
}, [isPartsAPIMode, allArticles.length, isPartsIndexMode, visibleEntities.length]);
|
||||
|
||||
|
||||
|
||||
const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => {
|
||||
setSelectedFilters(prev => ({
|
||||
...prev,
|
||||
[filterTitle]: Array.isArray(value) ? value : [value]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMobileFilterChange = (type: string, value: any) => {
|
||||
setSelectedFilters(prev => ({
|
||||
...prev,
|
||||
[type]: Array.isArray(value) ? value : [value]
|
||||
}));
|
||||
};
|
||||
|
||||
// Функция для сброса всех фильтров
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
setSelectedFilters({});
|
||||
setShowAllBrands(false);
|
||||
setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую
|
||||
}, []);
|
||||
|
||||
// Фильтрация по поиску и фильтрам для PartsAPI
|
||||
const filteredArticles = useMemo(() => {
|
||||
return allArticles.filter(article => {
|
||||
// Фильтрация по поиску
|
||||
if (searchQuery.trim()) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const articleTitle = [
|
||||
article.artSupBrand || '',
|
||||
article.artArticleNr || '',
|
||||
article.productGroup || ''
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (!articleTitle.includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по выбранным фильтрам
|
||||
const brandFilter = selectedFilters['Бренд'] || [];
|
||||
if (brandFilter.length > 0 && !brandFilter.includes(article.artSupBrand || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupFilter = selectedFilters['Группа товаров'] || [];
|
||||
if (groupFilter.length > 0 && !groupFilter.includes(article.productGroup || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [allArticles, searchQuery, selectedFilters]);
|
||||
|
||||
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
|
||||
const filteredEntities = visibleEntities;
|
||||
|
||||
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
|
||||
useEffect(() => {
|
||||
if (isPartsAPIMode) {
|
||||
setVisibleArticles(filteredArticles.slice(0, ITEMS_PER_PAGE));
|
||||
setCurrentPage(1);
|
||||
setIsLoadingMore(false);
|
||||
setVisibilityMap(new Map()); // Сбрасываем карту видимости при изменении фильтров
|
||||
setVisibleProductsCount(0); // Сбрасываем счетчик
|
||||
setLoadedArticlesCount(ITEMS_PER_PAGE); // Сбрасываем счетчик загруженных
|
||||
setShowEmptyState(false); // Сбрасываем пустое состояние
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPartsAPIMode, searchQuery, JSON.stringify(selectedFilters), filteredArticles.length]);
|
||||
|
||||
// Обновляем видимые товары при изменении поиска или фильтров для PartsIndex
|
||||
useEffect(() => {
|
||||
if (isPartsIndexMode) {
|
||||
// При изменении поиска или фильтров сбрасываем пагинацию
|
||||
setShowEmptyState(false);
|
||||
|
||||
// Если изменился поисковый запрос, нужно перезагрузить данные с сервера
|
||||
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
|
||||
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
|
||||
setPartsIndexPage(1);
|
||||
setHasMoreEntities(true);
|
||||
// refetch будет автоматически вызван при изменении partsIndexPage
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters)]);
|
||||
|
||||
// Управляем показом пустого состояния с задержкой
|
||||
useEffect(() => {
|
||||
if (isPartsAPIMode && !articlesLoading && !articlesError) {
|
||||
// Если товаров вообще нет - показываем сразу
|
||||
if (allArticles.length === 0) {
|
||||
setShowEmptyState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если товары есть, но нет видимых - ждем 2 секунды
|
||||
const timer = setTimeout(() => {
|
||||
setShowEmptyState(visibleProductsCount === 0 && allArticles.length > 0);
|
||||
}, 2000); // Даем 2 секунды на загрузку данных о предложениях
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
|
||||
// Для PartsIndex показываем пустое состояние если нет товаров
|
||||
setShowEmptyState(visibleEntities.length === 0);
|
||||
} else {
|
||||
setShowEmptyState(false);
|
||||
}
|
||||
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length,
|
||||
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, filteredEntities.length]);
|
||||
|
||||
// Функции для навигации по страницам PartsIndex
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (hasMoreEntities && !entitiesLoading) {
|
||||
setPartsIndexPage(prev => prev + 1);
|
||||
}
|
||||
}, [hasMoreEntities, entitiesLoading]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (partsIndexPage > 1 && !entitiesLoading) {
|
||||
setPartsIndexPage(prev => prev - 1);
|
||||
}
|
||||
}, [partsIndexPage, entitiesLoading]);
|
||||
|
||||
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
|
||||
const handleLoadMorePartsAPI = useCallback(async () => {
|
||||
if (isLoadingMore || !isPartsAPIMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const additionalCount = Math.min(ITEMS_PER_PAGE, filteredArticles.length - loadedArticlesCount);
|
||||
|
||||
if (additionalCount > 0) {
|
||||
const newArticles = filteredArticles.slice(loadedArticlesCount, loadedArticlesCount + additionalCount);
|
||||
setVisibleArticles(prev => [...prev, ...newArticles]);
|
||||
setLoadedArticlesCount(prev => prev + additionalCount);
|
||||
setTargetVisibleCount(prev => prev + ITEMS_PER_PAGE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка загрузки дополнительных товаров:', error);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isPartsAPIMode, loadedArticlesCount, filteredArticles, isLoadingMore]);
|
||||
|
||||
// Определяем есть ли еще товары для загрузки (только для PartsAPI)
|
||||
const hasMoreItems = useMemo(() => {
|
||||
if (isPartsAPIMode) {
|
||||
return loadedArticlesCount < filteredArticles.length;
|
||||
}
|
||||
return false;
|
||||
}, [isPartsAPIMode, loadedArticlesCount, filteredArticles.length]);
|
||||
|
||||
if (filtersLoading) {
|
||||
return <div className="py-8 text-center">Загрузка фильтров...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Catalog</title>
|
||||
<meta name="description" content="Catalog" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<CatalogInfoHeader
|
||||
title={
|
||||
isPartsAPIMode ? decodeURIComponent(categoryName as string || 'Запчасти') :
|
||||
isPartsIndexMode ? decodeURIComponent(categoryName as string || 'Товары') :
|
||||
"Аккумуляторы"
|
||||
}
|
||||
count={
|
||||
isPartsAPIMode ?
|
||||
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
|
||||
isPartsIndexMode ?
|
||||
(searchQuery.trim() || Object.keys(selectedFilters).length > 0 ?
|
||||
filteredEntities.length :
|
||||
entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) :
|
||||
3587
|
||||
}
|
||||
productName={
|
||||
isPartsAPIMode ? "запчасть" :
|
||||
isPartsIndexMode ? "товар" :
|
||||
"аккумулятор"
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Каталог" },
|
||||
...((isPartsAPIMode || isPartsIndexMode) ? [{ label: decodeURIComponent(categoryName as string || 'Товары') }] : [])
|
||||
]}
|
||||
showCount={true}
|
||||
showProductHelp={true}
|
||||
/>
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-13">
|
||||
<div className="w-layout-hflex flex-block-84">
|
||||
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
|
||||
<div className="code-embed-9 w-embed">
|
||||
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 4H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10 4H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 20H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 20H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 10V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16 18V22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>Фильтры</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPartsAPIMode ? (
|
||||
<div className="filters-desktop">
|
||||
<Filters
|
||||
filters={dynamicFilters}
|
||||
onFilterChange={handleDesktopFilterChange}
|
||||
filterValues={selectedFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isLoading={filtersGenerating}
|
||||
/>
|
||||
</div>
|
||||
) : isPartsIndexMode ? (
|
||||
<div className="filters-desktop">
|
||||
<Filters
|
||||
filters={catalogFilters}
|
||||
onFilterChange={handleDesktopFilterChange}
|
||||
filterValues={selectedFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="filters-desktop">
|
||||
<Filters
|
||||
filters={catalogFilters}
|
||||
onFilterChange={handleDesktopFilterChange}
|
||||
filterValues={selectedFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FiltersPanelMobile
|
||||
open={showFiltersMobile}
|
||||
onClose={() => setShowFiltersMobile(false)}
|
||||
filters={isPartsAPIMode ? dynamicFilters : catalogFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterValues={selectedFilters}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
/>
|
||||
|
||||
<div className="w-layout-vflex flex-block-14-copy-copy">
|
||||
{/* Индикатор загрузки для PartsAPI */}
|
||||
{isPartsAPIMode && articlesLoading && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<LoadingSpinner size="lg" text="Загружаем артикулы..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Индикатор загрузки для PartsIndex */}
|
||||
{isPartsIndexMode && entitiesLoading && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<LoadingSpinner size="lg" text="Загружаем товары..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сообщение об ошибке */}
|
||||
{isPartsAPIMode && articlesError && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="text-lg text-red-600">Ошибка загрузки артикулов: {articlesError.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сообщение об ошибке для PartsIndex */}
|
||||
{isPartsIndexMode && entitiesError && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="text-lg text-red-600">Ошибка загрузки товаров: {entitiesError.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение артикулов PartsAPI */}
|
||||
{isPartsAPIMode && visibleArticles.length > 0 && (
|
||||
<>
|
||||
{visibleArticles.map((article, idx) => (
|
||||
<ArticleCard
|
||||
key={`${article.artId}_${idx}`}
|
||||
article={article}
|
||||
index={idx}
|
||||
onVisibilityChange={handleVisibilityChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Кнопка "Показать еще" */}
|
||||
{hasMoreItems && (
|
||||
<div className="w-layout-hflex pagination">
|
||||
<button
|
||||
onClick={handleLoadMorePartsAPI}
|
||||
disabled={isLoadingMore}
|
||||
className="button_strock w-button"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
Загружаем...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Показать еще
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Отображение товаров PartsIndex */}
|
||||
{isPartsIndexMode && filteredEntities.length > 0 && (
|
||||
<>
|
||||
{filteredEntities
|
||||
.map((entity, idx) => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
const priceData = getPrice(productForPrice);
|
||||
const isLoadingPriceData = isLoadingPrice(productForPrice);
|
||||
|
||||
return {
|
||||
entity,
|
||||
idx,
|
||||
productForPrice,
|
||||
priceData,
|
||||
isLoadingPriceData,
|
||||
hasOffer: priceData !== null || isLoadingPriceData
|
||||
};
|
||||
})
|
||||
.filter(item => item.hasOffer) // Показываем только товары с предложениями или загружающиеся
|
||||
.map(({ entity, idx, productForPrice, priceData, isLoadingPriceData }) => {
|
||||
// Определяем цену для отображения
|
||||
let displayPrice = "Цена по запросу";
|
||||
let displayCurrency = "RUB";
|
||||
let priceElement;
|
||||
|
||||
if (isLoadingPriceData) {
|
||||
priceElement = <PriceSkeleton />;
|
||||
} else if (priceData && priceData.price) {
|
||||
displayPrice = `${priceData.price.toLocaleString('ru-RU')} ₽`;
|
||||
displayCurrency = priceData.currency || "RUB";
|
||||
}
|
||||
|
||||
return (
|
||||
<CatalogProductCard
|
||||
key={`${entity.id}_${idx}`}
|
||||
title={entity.originalName || entity.name?.name || 'Товар без названия'}
|
||||
brand={entity.brand.name}
|
||||
articleNumber={entity.code}
|
||||
brandName={entity.brand.name}
|
||||
image={entity.images?.[0] || ''}
|
||||
price={isLoadingPriceData ? "" : displayPrice}
|
||||
priceElement={priceElement}
|
||||
oldPrice=""
|
||||
discount=""
|
||||
currency={displayCurrency}
|
||||
productId={entity.id}
|
||||
artId={entity.id}
|
||||
offerKey={priceData?.offerKey}
|
||||
onAddToCart={() => {
|
||||
// Если цена не загружена, загружаем её и добавляем в корзину
|
||||
if (!priceData && !isLoadingPriceData) {
|
||||
loadPriceOnDemand(productForPrice);
|
||||
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если цена есть, добавляем в корзину
|
||||
if (priceData && priceData.price) {
|
||||
const itemToAdd = {
|
||||
productId: entity.id,
|
||||
offerKey: priceData.offerKey,
|
||||
name: entity.originalName || entity.name?.name || 'Товар без названия',
|
||||
description: `${entity.brand.name} ${entity.code}`,
|
||||
brand: entity.brand.name,
|
||||
article: entity.code,
|
||||
price: priceData.price,
|
||||
currency: priceData.currency || 'RUB',
|
||||
quantity: 1,
|
||||
deliveryTime: '1-3 дня',
|
||||
warehouse: 'Parts Index',
|
||||
supplier: 'Parts Index',
|
||||
isExternal: true,
|
||||
image: entity.images?.[0] || '',
|
||||
};
|
||||
|
||||
addItem(itemToAdd);
|
||||
|
||||
// Показываем уведомление
|
||||
toast.success(`Товар "${entity.brand.name} ${entity.code}" добавлен в корзину за ${priceData.price.toLocaleString('ru-RU')} ₽`);
|
||||
} else {
|
||||
toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Пагинация для PartsIndex */}
|
||||
<div className="w-layout-hflex pagination">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={partsIndexPage <= 1 || entitiesLoading}
|
||||
className="button_strock w-button mr-2"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<span className="flex items-center px-4 text-gray-600">
|
||||
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMoreEntities || entitiesLoading}
|
||||
className="button_strock w-button ml-2"
|
||||
>
|
||||
{entitiesLoading ? 'Загрузка...' : 'Вперед →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Отладочная информация */}
|
||||
{isPartsIndexMode && (
|
||||
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
|
||||
<div>🔍 Отладка PartsIndex:</div>
|
||||
<div>• hasMoreItems: {hasMoreItems ? 'да' : 'нет'}</div>
|
||||
<div>• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
|
||||
<div>• entitiesPage: {entitiesPage}</div>
|
||||
<div>• visibleEntities: {visibleEntities.length}</div>
|
||||
<div>• filteredEntities: {filteredEntities.length}</div>
|
||||
<div>• groupId: {groupId || 'отсутствует'}</div>
|
||||
<div>• isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div>
|
||||
<div>• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
|
||||
<div>• catalogId: {catalogId || 'отсутствует'}</div>
|
||||
<div>• Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Пустое состояние для PartsAPI */}
|
||||
{isPartsAPIMode && !articlesLoading && !articlesError && showEmptyState && (
|
||||
<CatalogEmptyState
|
||||
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
|
||||
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
|
||||
onResetFilters={handleResetFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Пустое состояние для PartsIndex */}
|
||||
{isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && (
|
||||
<CatalogEmptyState
|
||||
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
|
||||
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
|
||||
onResetFilters={handleResetFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Каталог PartsIndex без группы */}
|
||||
{isPartsIndexCatalogOnly && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="text-gray-500 text-lg mb-4">Выберите подкатегорию</div>
|
||||
<div className="text-gray-400 text-sm">Для просмотра товаров необходимо выбрать конкретную подкатегорию из меню.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Обычные товары (не PartsAPI/PartsIndex) */}
|
||||
{!isPartsAPIMode && !isPartsIndexMode && !isPartsIndexCatalogOnly && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="text-gray-500 text-lg mb-4">Раздел в разработке</div>
|
||||
<div className="text-gray-400 text-sm">Данные для этой категории скоро появятся.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!isPartsAPIMode && !isPartsIndexMode && <CatalogPagination />}
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/pages/checkout.tsx
Normal file
20
src/pages/checkout.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function Checkout() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Checkout</title>
|
||||
<meta name="description" content="Checkout" />
|
||||
</Head>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из checkout.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
{/* Сохрани все классы для стилей. */}
|
||||
{/* TODO: Перевести формы и интерактив на React позже */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
44
src/pages/contacts.tsx
Normal file
44
src/pages/contacts.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import InfoContacts from "@/components/contacts/InfoContacts";
|
||||
import MapContacts from "@/components/contacts/MapContacts";
|
||||
import OrderContacts from "@/components/contacts/OrderContacts";
|
||||
import LegalContacts from "@/components/contacts/LegalContacts";
|
||||
|
||||
const Contacts = () => (
|
||||
<>
|
||||
<Head>
|
||||
<title>Contacts</title>
|
||||
<meta name="description" content="Contacts" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoContacts />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<div className="w-layout-vflex flex-block-72">
|
||||
<div className="w-layout-vflex flex-block-97">
|
||||
<OrderContacts />
|
||||
<LegalContacts />
|
||||
</div>
|
||||
<MapContacts />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
|
||||
export default Contacts;
|
20
src/pages/detail_category.tsx
Normal file
20
src/pages/detail_category.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailCategory() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail Category</title>
|
||||
<meta name="description" content="Detail Category" />
|
||||
</Head>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_category.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
{/* Сохрани все классы для стилей. */}
|
||||
{/* TODO: Перевести формы и интерактив на React позже */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/pages/detail_product.tsx
Normal file
20
src/pages/detail_product.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailProduct() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail Product</title>
|
||||
<meta name="description" content="Detail Product" />
|
||||
</Head>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_product.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
{/* Сохрани все классы для стилей. */}
|
||||
{/* TODO: Перевести формы и интерактив на React позже */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/pages/detail_sku.tsx
Normal file
20
src/pages/detail_sku.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function DetailSku() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Detail SKU</title>
|
||||
<meta name="description" content="Detail SKU" />
|
||||
</Head>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из detail_sku.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
{/* Сохрани все классы для стилей. */}
|
||||
{/* TODO: Перевести формы и интерактив на React позже */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
178
src/pages/favorite.tsx
Normal file
178
src/pages/favorite.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "../components/MobileMenuBottomSection";
|
||||
import FavoriteInfo from "@/components/FavoriteInfo";
|
||||
import Filters, { FilterConfig } from "@/components/Filters";
|
||||
import FiltersPanelMobile from "@/components/FiltersPanelMobile";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import CartRecommended from "../components/CartRecommended";
|
||||
import FavoriteList from "../components/FavoriteList";
|
||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||
|
||||
export default function Favorite() {
|
||||
const { favorites } = useFavorites();
|
||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<{[key: string]: any}>({});
|
||||
const [sortBy, setSortBy] = useState<'name' | 'brand' | 'price' | 'date'>('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Создаем динамические фильтры на основе данных избранного
|
||||
const dynamicFilters: FilterConfig[] = useMemo(() => {
|
||||
const filters: FilterConfig[] = [];
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
// Фильтр по производителю
|
||||
const brands = [...new Set(favorites.map(item => item.brand).filter(Boolean))].sort();
|
||||
if (brands.length > 1) {
|
||||
filters.push({
|
||||
type: "dropdown",
|
||||
title: "Производитель",
|
||||
options: brands,
|
||||
multi: true,
|
||||
showAll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтр по цене
|
||||
const prices = favorites
|
||||
.map(item => item.price)
|
||||
.filter((price): price is number => typeof price === 'number' && price > 0);
|
||||
|
||||
if (prices.length > 0) {
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
|
||||
if (maxPrice > minPrice) {
|
||||
filters.push({
|
||||
type: "range",
|
||||
title: "Цена (₽)",
|
||||
min: Math.floor(minPrice),
|
||||
max: Math.ceil(maxPrice),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [favorites]);
|
||||
|
||||
const handleFilterChange = (type: string, value: any) => {
|
||||
setFilterValues(prev => ({
|
||||
...prev,
|
||||
[type]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setShowFiltersMobile(false);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilterValues({});
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
// Подсчитываем количество активных фильтров
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (searchQuery.trim()) count++;
|
||||
|
||||
Object.entries(filterValues).forEach(([key, value]) => {
|
||||
if (Array.isArray(value) && value.length > 0) count++;
|
||||
else if (value && !Array.isArray(value)) count++;
|
||||
});
|
||||
|
||||
return count;
|
||||
}, [searchQuery, filterValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Избранное - Protek Auto</title>
|
||||
<meta name="description" content="Ваши избранные товары" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<FavoriteInfo />
|
||||
<section className="main">
|
||||
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-84">
|
||||
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
|
||||
<div className="code-embed-9 w-embed">
|
||||
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 4H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10 4H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 20H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 20H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 10V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16 18V22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>Фильтры</div>
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="ml-2 bg-red-600 text-white text-xs rounded-full px-2 py-1 min-w-[20px] text-center">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="text-red-600 hover:text-red-800 text-sm underline ml-4"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FiltersPanelMobile
|
||||
filters={dynamicFilters}
|
||||
open={showFiltersMobile}
|
||||
onClose={() => setShowFiltersMobile(false)}
|
||||
onApply={handleApplyFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex cart-list">
|
||||
<FavoriteList
|
||||
filters={dynamicFilters}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSortBy}
|
||||
onSortOrderChange={setSortOrder}
|
||||
/>
|
||||
<CartRecommended />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
50
src/pages/index.tsx
Normal file
50
src/pages/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import styles from "@/styles/Home.module.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import HeroSlider from "@/components/index/HeroSlider";
|
||||
import CatalogSection from "@/components/index/CatalogSection";
|
||||
import AvailableParts from "@/components/index/AvailableParts";
|
||||
import NewsAndPromos from "@/components/index/NewsAndPromos";
|
||||
import AboutHelp from "@/components/about/AboutHelp";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Protek</title>
|
||||
<meta name="description" content="Protek" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<HeroSlider />
|
||||
<CatalogSection />
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<AboutHelp />
|
||||
</div>
|
||||
<AvailableParts />
|
||||
<NewsAndPromos />
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
64
src/pages/news-open.tsx
Normal file
64
src/pages/news-open.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Header from "@/components/Header";
|
||||
import Head from "next/head";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import InfoNewsOpen from "@/components/news-open/InfoNewsOpen";
|
||||
import ContentNews from "@/components/news-open/ContentNews";
|
||||
import NewsCard from "@/components/news/NewsCard";
|
||||
|
||||
export default function NewsOpen() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>news open</title>
|
||||
<meta content="news open" property="og:title" />
|
||||
<meta content="news open" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
<link href="/css/normalize.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/protekproject.webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoNewsOpen />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<div className="w-layout-vflex flex-block-72">
|
||||
<div className="w-layout-vflex flex-block-97">
|
||||
<ContentNews />
|
||||
</div>
|
||||
<div className="w-layout-vflex lastnews">
|
||||
<NewsCard
|
||||
key={1}
|
||||
title="Kia Syros будет выделяться необычным стилем"
|
||||
description="Компания Kia готова представить новый кроссовер Syros"
|
||||
category="Новости компании"
|
||||
date="17.12.2024"
|
||||
image="/images/news_img.png"
|
||||
/>
|
||||
<NewsCard
|
||||
key={2}
|
||||
title="Kia Syros будет выделяться необычным стилем"
|
||||
description="Компания Kia готова представить новый кроссовер Syros"
|
||||
category="Новости компании"
|
||||
date="17.12.2024"
|
||||
image="/images/news_img.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
52
src/pages/news.tsx
Normal file
52
src/pages/news.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import InfoNews from "@/components/news/InfoNews";
|
||||
import NewsMenu from "@/components/news/NewsMenu";
|
||||
import NewsCard from "@/components/news/NewsCard";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
|
||||
export default function News() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>News</title>
|
||||
<meta name="description" content="News" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoNews />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex">
|
||||
<NewsMenu />
|
||||
<div className="w-layout-hflex main-news">
|
||||
{Array(12).fill(0).map((_, i) => (
|
||||
<NewsCard
|
||||
key={i}
|
||||
title="Kia Syros будет выделяться необычным стилем"
|
||||
description="Компания Kia готова представить новый кроссовер Syros"
|
||||
category="Новости компании"
|
||||
date="17.12.2024"
|
||||
image="/images/news_img.png"
|
||||
/>
|
||||
))}
|
||||
<div className="w-layout-hflex pagination">
|
||||
<a href="#" className="button_strock w-button">Показать ещё</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
20
src/pages/order-confirmation.tsx
Normal file
20
src/pages/order-confirmation.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function OrderConfirmation() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Order Confirmation</title>
|
||||
<meta name="description" content="Order Confirmation" />
|
||||
</Head>
|
||||
<Header />
|
||||
{/* Вставь сюда содержимое <body> из order-confirmation.html, преобразовав в JSX. Все пути к картинкам и svg поменяй на /images/... */}
|
||||
{/* Пример: <img src="/images/logo.svg" ... /> */}
|
||||
{/* Сохрани все классы для стилей. */}
|
||||
{/* TODO: Перевести формы и интерактив на React позже */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
170
src/pages/payment/cancelled.tsx
Normal file
170
src/pages/payment/cancelled.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function PaymentCancelled() {
|
||||
const router = useRouter();
|
||||
const [paymentId, setPaymentId] = useState<string | null>(null);
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Получаем параметры из URL
|
||||
const { payment_id, order_id } = router.query;
|
||||
|
||||
if (payment_id) {
|
||||
setPaymentId(payment_id as string);
|
||||
}
|
||||
|
||||
if (order_id) {
|
||||
setOrderId(order_id as string);
|
||||
}
|
||||
}, [router.query]);
|
||||
|
||||
const handleReturnToCart = () => {
|
||||
router.push('/cart');
|
||||
};
|
||||
|
||||
const handleContinueShopping = () => {
|
||||
router.push('/catalog');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Оплата отменена - Protekauto</title>
|
||||
<meta name="description" content="Оплата заказа была отменена" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<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>
|
||||
<div className="text-block-3">Оплата отменена</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex" style={{ alignItems: 'center', textAlign: 'center', padding: '4rem 0' }}>
|
||||
|
||||
{/* Иконка отмены */}
|
||||
<div style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#F59E0B',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 18L18 6M6 6L18 18" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<h1 className="heading" style={{ marginBottom: '1rem', color: '#F59E0B' }}>
|
||||
Оплата отменена
|
||||
</h1>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="text-block-4" style={{ marginBottom: '2rem', maxWidth: 600 }}>
|
||||
Вы отменили процесс оплаты. Ваш заказ сохранен в корзине,
|
||||
и вы можете завершить оплату в любое время.
|
||||
</div>
|
||||
|
||||
{/* Информация о заказе */}
|
||||
{(paymentId || orderId) && (
|
||||
<div style={{
|
||||
backgroundColor: '#FFFBEB',
|
||||
border: '1px solid #FDE68A',
|
||||
borderRadius: 8,
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
maxWidth: 400,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1.1rem', fontWeight: 600, color: '#92400E' }}>
|
||||
Информация о заказе
|
||||
</h3>
|
||||
{orderId && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Номер заказа:</strong> {orderId}
|
||||
</div>
|
||||
)}
|
||||
{paymentId && (
|
||||
<div>
|
||||
<strong>ID платежа:</strong> {paymentId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="w-layout-hflex" style={{ gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="submit-button fill w-button"
|
||||
onClick={handleReturnToCart}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
Вернуться в корзину
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="submit-button w-button"
|
||||
onClick={handleContinueShopping}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
backgroundColor: 'transparent',
|
||||
border: '2px solid var(--_button---primary)',
|
||||
color: 'var(--_button---primary)'
|
||||
}}
|
||||
>
|
||||
Продолжить покупки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<div style={{
|
||||
marginTop: '3rem',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: '#F0F9FF',
|
||||
border: '1px solid #BAE6FD',
|
||||
borderRadius: 8,
|
||||
maxWidth: 600,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 1rem 0', color: '#0C4A6E' }}>
|
||||
Что произошло?
|
||||
</h4>
|
||||
<div style={{ color: '#0369A1', lineHeight: 1.6 }}>
|
||||
Процесс оплаты был прерван по вашему запросу. Это может произойти, если вы:
|
||||
<ul style={{ margin: '0.5rem 0 0 0', paddingLeft: '1.5rem' }}>
|
||||
<li>Нажали кнопку "Отмена" на странице оплаты</li>
|
||||
<li>Закрыли окно браузера во время оплаты</li>
|
||||
<li>Вернулись на предыдущую страницу</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
200
src/pages/payment/failed.tsx
Normal file
200
src/pages/payment/failed.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function PaymentFailed() {
|
||||
const router = useRouter();
|
||||
const [paymentId, setPaymentId] = useState<string | null>(null);
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Получаем параметры из URL
|
||||
const { payment_id, order_id, error } = router.query;
|
||||
|
||||
if (payment_id) {
|
||||
setPaymentId(payment_id as string);
|
||||
}
|
||||
|
||||
if (order_id) {
|
||||
setOrderId(order_id as string);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error as string);
|
||||
}
|
||||
}, [router.query]);
|
||||
|
||||
const handleRetryPayment = () => {
|
||||
// Возвращаемся в корзину для повторной попытки
|
||||
router.push('/cart');
|
||||
};
|
||||
|
||||
const handleContactSupport = () => {
|
||||
router.push('/contacts');
|
||||
};
|
||||
|
||||
const handleContinueShopping = () => {
|
||||
router.push('/catalog');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Ошибка оплаты - Protekauto</title>
|
||||
<meta name="description" content="Произошла ошибка при оплате заказа" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<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>
|
||||
<div className="text-block-3">Ошибка оплаты</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex" style={{ alignItems: 'center', textAlign: 'center', padding: '4rem 0' }}>
|
||||
|
||||
{/* Иконка ошибки */}
|
||||
<div style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#EF4444',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 9V13M12 17H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<h1 className="heading" style={{ marginBottom: '1rem', color: '#EF4444' }}>
|
||||
Ошибка оплаты
|
||||
</h1>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="text-block-4" style={{ marginBottom: '2rem', maxWidth: 600 }}>
|
||||
К сожалению, произошла ошибка при обработке платежа.
|
||||
Ваш заказ не был оплачен, но вы можете попробовать еще раз.
|
||||
</div>
|
||||
|
||||
{/* Информация об ошибке */}
|
||||
{(paymentId || orderId || errorMessage) && (
|
||||
<div style={{
|
||||
backgroundColor: '#FEF2F2',
|
||||
border: '1px solid #FECACA',
|
||||
borderRadius: 8,
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
maxWidth: 400,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1.1rem', fontWeight: 600, color: '#DC2626' }}>
|
||||
Детали ошибки
|
||||
</h3>
|
||||
{orderId && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Номер заказа:</strong> {orderId}
|
||||
</div>
|
||||
)}
|
||||
{paymentId && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>ID платежа:</strong> {paymentId}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div style={{ color: '#DC2626' }}>
|
||||
<strong>Ошибка:</strong> {errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="w-layout-hflex" style={{ gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="submit-button fill w-button"
|
||||
onClick={handleRetryPayment}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="submit-button w-button"
|
||||
onClick={handleContactSupport}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
backgroundColor: 'transparent',
|
||||
border: '2px solid var(--_button---primary)',
|
||||
color: 'var(--_button---primary)'
|
||||
}}
|
||||
>
|
||||
Связаться с поддержкой
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
onClick={handleContinueShopping}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6B7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Продолжить покупки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<div style={{
|
||||
marginTop: '3rem',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: '#F3F4F6',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: 8,
|
||||
maxWidth: 600,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 1rem 0', color: '#374151' }}>
|
||||
Возможные причины ошибки:
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.5rem', color: '#6B7280' }}>
|
||||
<li>Недостаточно средств на карте</li>
|
||||
<li>Карта заблокирована или просрочена</li>
|
||||
<li>Неверно введены данные карты</li>
|
||||
<li>Технические проблемы платежной системы</li>
|
||||
<li>Превышен лимит по карте</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
256
src/pages/payment/invoice.tsx
Normal file
256
src/pages/payment/invoice.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
|
||||
const InvoicePage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { orderId, orderNumber } = router.query;
|
||||
const [orderData, setOrderData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Здесь можно загрузить данные заказа если нужно
|
||||
// Пока используем базовую информацию из query параметров
|
||||
}, [orderId, orderNumber]);
|
||||
|
||||
const handleBackToHome = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleGoToProfile = () => {
|
||||
router.push('/profile/orders');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Счёт на оплату - Протек Авто</title>
|
||||
<meta name="description" content="Счёт на оплату заказа" />
|
||||
</Head>
|
||||
|
||||
<div className="w-layout-vflex" style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
{/* Заголовок */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
backgroundColor: '#28a745',
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 12l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="12" r="9" stroke="white" strokeWidth="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Заказ оформлен!
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginBottom: '0'
|
||||
}}>
|
||||
Заказ №{orderNumber} успешно создан
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Информация о счёте */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
Реквизиты для оплаты
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Получатель
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
ООО "Протек Авто"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
ИНН
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
1234567890
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
КПП
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
123456001
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Расчётный счёт
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
40702810123456789012
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Банк
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
ПАО "Сбербанк России"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
БИК
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
044525225
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Корреспондентский счёт
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333' }}>
|
||||
30101810400000000225
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Важная информация */}
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#856404',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Важно!
|
||||
</h4>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#856404',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
• В назначении платежа обязательно укажите номер заказа: <strong>№{orderNumber}</strong>
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#856404',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
• Счёт на оплату будет выслан на указанную при оформлении заказа электронную почту
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#856404',
|
||||
marginBottom: '0'
|
||||
}}>
|
||||
• После поступления оплаты заказ будет передан в обработку
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleGoToProfile}
|
||||
style={{
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#0056b3';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#007bff';
|
||||
}}
|
||||
>
|
||||
Мои заказы
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBackToHome}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: '#007bff',
|
||||
border: '1px solid #007bff',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#007bff';
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#007bff';
|
||||
}}
|
||||
>
|
||||
На главную
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicePage;
|
212
src/pages/payment/success.tsx
Normal file
212
src/pages/payment/success.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMutation, ApolloProvider } from "@apollo/client";
|
||||
import { gql } from "@apollo/client";
|
||||
import { apolloClient } from "@/lib/apollo";
|
||||
|
||||
const CONFIRM_PAYMENT = gql`
|
||||
mutation ConfirmPayment($orderId: ID!) {
|
||||
confirmPayment(orderId: $orderId) {
|
||||
id
|
||||
orderNumber
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function PaymentSuccessContent() {
|
||||
const router = useRouter();
|
||||
const [paymentId, setPaymentId] = useState<string | null>(null);
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const [confirmPayment] = useMutation(CONFIRM_PAYMENT);
|
||||
|
||||
useEffect(() => {
|
||||
// Получаем параметры из URL
|
||||
const { payment_id, orderId, orderNumber } = router.query;
|
||||
|
||||
if (payment_id) {
|
||||
setPaymentId(payment_id as string);
|
||||
}
|
||||
|
||||
if (orderId) {
|
||||
setOrderId(orderId as string);
|
||||
|
||||
// Проверяем авторизацию перед обновлением статуса
|
||||
const userData = localStorage.getItem('userData');
|
||||
console.log('PaymentSuccess: userData из localStorage:', userData);
|
||||
|
||||
if (!userData) {
|
||||
console.log('PaymentSuccess: пользователь не авторизован, пропускаем обновление статуса');
|
||||
return;
|
||||
}
|
||||
|
||||
// Автоматически подтверждаем оплату заказа
|
||||
console.log('PaymentSuccess: подтверждаем оплату заказа', orderId);
|
||||
confirmPayment({
|
||||
variables: {
|
||||
orderId: orderId as string
|
||||
}
|
||||
}).then(() => {
|
||||
console.log('Оплата заказа подтверждена');
|
||||
}).catch((error: any) => {
|
||||
console.error('Ошибка подтверждения оплаты:', error);
|
||||
});
|
||||
}
|
||||
}, [router.query, confirmPayment]);
|
||||
|
||||
const handleContinueShopping = () => {
|
||||
router.push('/catalog');
|
||||
};
|
||||
|
||||
const handleViewOrders = () => {
|
||||
router.push('/profile-orders');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Оплата прошла успешно - Protekauto</title>
|
||||
<meta name="description" content="Ваш заказ успешно оплачен" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="/images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<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>
|
||||
<div className="text-block-3">Оплата завершена</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex" style={{ alignItems: 'center', textAlign: 'center', padding: '4rem 0' }}>
|
||||
|
||||
{/* Иконка успеха */}
|
||||
<div style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#10B981',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<h1 className="heading" style={{ marginBottom: '1rem', color: '#10B981' }}>
|
||||
Оплата прошла успешно!
|
||||
</h1>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="text-block-4" style={{ marginBottom: '2rem', maxWidth: 600 }}>
|
||||
Спасибо за ваш заказ! Оплата была успешно обработана.
|
||||
Мы отправили подтверждение на вашу электронную почту.
|
||||
</div>
|
||||
|
||||
{/* Информация о заказе */}
|
||||
{(paymentId || orderId) && (
|
||||
<div style={{
|
||||
backgroundColor: '#F9FAFB',
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: 8,
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
maxWidth: 400,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Детали платежа
|
||||
</h3>
|
||||
{orderId && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Номер заказа:</strong> {orderId}
|
||||
</div>
|
||||
)}
|
||||
{paymentId && (
|
||||
<div>
|
||||
<strong>ID платежа:</strong> {paymentId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="w-layout-hflex" style={{ gap: '1rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="submit-button fill w-button"
|
||||
onClick={handleViewOrders}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
Мои заказы
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="submit-button w-button"
|
||||
onClick={handleContinueShopping}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
backgroundColor: 'transparent',
|
||||
border: '2px solid var(--_button---primary)',
|
||||
color: 'var(--_button---primary)'
|
||||
}}
|
||||
>
|
||||
Продолжить покупки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<div style={{
|
||||
marginTop: '3rem',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: '#FEF3C7',
|
||||
border: '1px solid #F59E0B',
|
||||
borderRadius: 8,
|
||||
maxWidth: 600,
|
||||
width: '100%'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 1rem 0', color: '#92400E' }}>
|
||||
Что дальше?
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.5rem', color: '#92400E' }}>
|
||||
<li>Мы обработаем ваш заказ в течение 1-2 рабочих дней</li>
|
||||
<li>Вы получите уведомление о статусе заказа на email</li>
|
||||
<li>Отслеживать заказ можно в разделе "Мои заказы"</li>
|
||||
<li>При вопросах обращайтесь в службу поддержки</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentSuccess() {
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<PaymentSuccessContent />
|
||||
</ApolloProvider>
|
||||
);
|
||||
}
|
39
src/pages/payments-method.tsx
Normal file
39
src/pages/payments-method.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import Help from "@/components/Help";
|
||||
import InfoPayments from "@/components/payments/InfoPayments";
|
||||
import PaymentsDetails from "@/components/payments/PaymentsDetails";
|
||||
import DeliveryInfo from "@/components/payments/DeliveryInfo";
|
||||
import PaymentsCompony from "@/components/payments/PaymentsCompony";
|
||||
|
||||
export default function PaymentsMethod() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Payments Method</title>
|
||||
<meta name="description" content="Payments Method" />
|
||||
</Head>
|
||||
<InfoPayments />
|
||||
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<PaymentsDetails />
|
||||
<DeliveryInfo />
|
||||
<PaymentsCompony />
|
||||
<Help />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
142
src/pages/paypal-checkout.tsx
Normal file
142
src/pages/paypal-checkout.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
const PaypalCheckout: React.FC = () => (
|
||||
<div className="w-commerce-commercepaypalcheckoutformcontainer">
|
||||
<div className="w-commerce-commercelayoutcontainer w-container">
|
||||
<div className="w-commerce-commercelayoutmain">
|
||||
{/* Shipping Method */}
|
||||
<form className="w-commerce-commercecheckoutshippingmethodswrapper">
|
||||
<div className="w-commerce-commercecheckoutblockheader">
|
||||
<h2>Shipping Method</h2>
|
||||
</div>
|
||||
<fieldset>
|
||||
<div className="w-commerce-commercecheckoutshippingmethodslist">
|
||||
<label className="w-commerce-commercecheckoutshippingmethoditem">
|
||||
<input required type="radio" name="shipping-method-choice" />
|
||||
<div className="w-commerce-commercecheckoutshippingmethoddescriptionblock">
|
||||
<div className="w-commerce-commerceboldtextblock"></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div></div>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{display:'none'}} className="w-commerce-commercecheckoutshippingmethodsemptystate">
|
||||
<div>No shipping methods are available for the address given.</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{/* Customer Info */}
|
||||
<div className="w-commerce-commercecheckoutcustomerinfosummarywrapper">
|
||||
<div className="w-commerce-commercecheckoutsummaryblockheader">
|
||||
<h2>Customer Information</h2>
|
||||
</div>
|
||||
<fieldset className="w-commerce-commercecheckoutblockcontent">
|
||||
<div className="w-commerce-commercecheckoutrow">
|
||||
<div className="w-commerce-commercecheckoutcolumn">
|
||||
<div className="w-commerce-commercecheckoutsummaryitem">
|
||||
<label className="w-commerce-commercecheckoutsummarylabel">Email</label>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-commerce-commercecheckoutcolumn">
|
||||
<div className="w-commerce-commercecheckoutsummaryitem">
|
||||
<label className="w-commerce-commercecheckoutsummarylabel">Shipping Address</label>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div className="w-commerce-commercecheckoutsummaryflexboxdiv">
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/* Payment Info */}
|
||||
<div className="w-commerce-commercecheckoutpaymentsummarywrapper">
|
||||
<div className="w-commerce-commercecheckoutsummaryblockheader">
|
||||
<h2>Payment Info</h2>
|
||||
</div>
|
||||
<fieldset className="w-commerce-commercecheckoutblockcontent">
|
||||
<div className="w-commerce-commercecheckoutrow">
|
||||
<div className="w-commerce-commercecheckoutcolumn">
|
||||
<div className="w-commerce-commercecheckoutsummaryitem">
|
||||
<label className="w-commerce-commercecheckoutsummarylabel">Payment Info</label>
|
||||
<div className="w-commerce-commercecheckoutsummaryflexboxdiv">
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
</div>
|
||||
<div className="w-commerce-commercecheckoutsummaryflexboxdiv">
|
||||
<div></div>
|
||||
<div> / </div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-commerce-commercecheckoutcolumn">
|
||||
<div className="w-commerce-commercecheckoutsummaryitem">
|
||||
<label className="w-commerce-commercecheckoutsummarylabel">Billing Address</label>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div className="w-commerce-commercecheckoutsummaryflexboxdiv">
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
<div className="w-commerce-commercecheckoutsummarytextspacingondiv"></div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/* Items in Order */}
|
||||
<div className="w-commerce-commercecheckoutorderitemswrapper">
|
||||
<div className="w-commerce-commercecheckoutsummaryblockheader">
|
||||
<h2>Items in Order</h2>
|
||||
</div>
|
||||
<fieldset className="w-commerce-commercecheckoutblockcontent">
|
||||
<div role="list" className="w-commerce-commercecheckoutorderitemslist"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-commerce-commercelayoutsidebar">
|
||||
<div className="w-commerce-commercecheckoutordersummarywrapper">
|
||||
<div className="w-commerce-commercecheckoutsummaryblockheader">
|
||||
<h2>Order Summary</h2>
|
||||
</div>
|
||||
<fieldset className="w-commerce-commercecheckoutblockcontent">
|
||||
<div className="w-commerce-commercecheckoutsummarylineitem">
|
||||
<div>Subtotal</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="w-commerce-commercecheckoutordersummaryextraitemslist">
|
||||
<div className="w-commerce-commercecheckoutordersummaryextraitemslistitem">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-commerce-commercecheckoutsummarylineitem">
|
||||
<div>Total</div>
|
||||
<div className="w-commerce-commercecheckoutsummarytotal"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<button className="w-commerce-commercecheckoutplaceorderbutton">Place Order</button>
|
||||
<div style={{display:'none'}} className="w-commerce-commercepaypalcheckouterrorstate">
|
||||
<div aria-live="polite" className="w-checkout-error-msg">
|
||||
There was an error processing your customer info. Please try again, or contact us if you continue to have problems.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PaypalCheckout;
|
39
src/pages/profile-acts.tsx
Normal file
39
src/pages/profile-acts.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ProfileSidebar from '@/components/ProfileSidebar';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileActsMain from '@/components/profile/ProfileActsMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import NotificationMane from "@/components/profile/NotificationMane";
|
||||
import Head from "next/head";
|
||||
|
||||
const ProfileActsPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileActs</title>
|
||||
<meta content="ProfileActs" property="og:title" />
|
||||
<meta content="ProfileActs" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileActsMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileActsPage;
|
40
src/pages/profile-addresses.tsx
Normal file
40
src/pages/profile-addresses.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileAddressesMain from '@/components/profile/ProfileAddressesMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
const ProfileAddressesPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileAddresses</title>
|
||||
<meta content="ProfileAddresses" property="og:title" />
|
||||
<meta content="ProfileAddresses" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileAddressesMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileAddressesPage;
|
32
src/pages/profile-announcement.tsx
Normal file
32
src/pages/profile-announcement.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ProfileSidebar from '@/components/ProfileSidebar';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileAnnouncementMain from '@/components/profile/ProfileAnnouncementMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import NotificationMane from "@/components/profile/NotificationMane";
|
||||
|
||||
|
||||
const ProfileAnnouncementPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px]">
|
||||
<LKMenu />
|
||||
<ProfileAnnouncementMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileAnnouncementPage;
|
84
src/pages/profile-balance.tsx
Normal file
84
src/pages/profile-balance.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_CLIENT_ME } from '@/lib/graphql';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileBalanceMain from '@/components/profile/ProfileBalanceMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
const ProfileBalancePage = () => {
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME, {
|
||||
skip: !isAuthenticated,
|
||||
onCompleted: (data) => {
|
||||
// Проверяем есть ли у клиента юридические лица
|
||||
if (!data?.clientMe?.legalEntities?.length) {
|
||||
// Если нет юридических лиц, перенаправляем на настройки
|
||||
router.push('/profile-settings?tab=legal');
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка загрузки данных клиента:', error);
|
||||
// Если ошибка авторизации, перенаправляем на главную
|
||||
router.push('/');
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем авторизацию
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
}, [router]);
|
||||
|
||||
// Показываем загрузку пока проверяем авторизацию и данные
|
||||
if (!isAuthenticated || clientLoading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div className="flex flex-col justify-center items-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
|
||||
<div className="mt-4 text-gray-600">Загрузка...</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileBalance</title>
|
||||
<meta content="ProfileBalance" property="og:title" />
|
||||
<meta content="ProfileBalance" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileBalanceMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileBalancePage;
|
40
src/pages/profile-gar.tsx
Normal file
40
src/pages/profile-gar.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileGarageMain from '@/components/profile/ProfileGarageMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
const ProfileGaragePage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileGarage</title>
|
||||
<meta content="ProfileGarage" property="og:title" />
|
||||
<meta content="ProfileGarage" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileGarageMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileGaragePage;
|
698
src/pages/profile-garage.tsx
Normal file
698
src/pages/profile-garage.tsx
Normal file
@ -0,0 +1,698 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery, useMutation } from '@apollo/client';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ProfileSidebar from '@/components/ProfileSidebar';
|
||||
import {
|
||||
GET_USER_VEHICLES,
|
||||
GET_VEHICLE_SEARCH_HISTORY,
|
||||
CREATE_USER_VEHICLE,
|
||||
DELETE_USER_VEHICLE,
|
||||
ADD_VEHICLE_FROM_SEARCH,
|
||||
DELETE_SEARCH_HISTORY_ITEM,
|
||||
SEARCH_VEHICLE_BY_VIN,
|
||||
UserVehicle,
|
||||
VehicleSearchHistory,
|
||||
UserVehicleInput
|
||||
} from '@/lib/graphql/garage';
|
||||
|
||||
const ProfileGaragePage = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newCar, setNewCar] = useState<UserVehicleInput>({
|
||||
name: '',
|
||||
vin: '',
|
||||
comment: ''
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// GraphQL queries
|
||||
const { data: vehiclesData, loading: vehiclesLoading, refetch: refetchVehicles } = useQuery(GET_USER_VEHICLES);
|
||||
const { data: historyData, loading: historyLoading, refetch: refetchHistory } = useQuery(GET_VEHICLE_SEARCH_HISTORY);
|
||||
|
||||
// GraphQL mutations
|
||||
const [createVehicle] = useMutation(CREATE_USER_VEHICLE, {
|
||||
onCompleted: () => {
|
||||
refetchVehicles();
|
||||
setNewCar({ name: '', vin: '', comment: '' });
|
||||
setShowAddForm(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка создания автомобиля:', error);
|
||||
alert('Ошибка при добавлении автомобиля');
|
||||
}
|
||||
});
|
||||
|
||||
const [deleteVehicle] = useMutation(DELETE_USER_VEHICLE, {
|
||||
onCompleted: () => {
|
||||
refetchVehicles();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка удаления автомобиля:', error);
|
||||
alert('Ошибка при удалении автомобиля');
|
||||
}
|
||||
});
|
||||
|
||||
const [addFromSearch] = useMutation(ADD_VEHICLE_FROM_SEARCH, {
|
||||
onCompleted: () => {
|
||||
refetchVehicles();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка добавления из истории:', error);
|
||||
alert('Ошибка при добавлении автомобиля из истории');
|
||||
}
|
||||
});
|
||||
|
||||
const [deleteHistoryItem] = useMutation(DELETE_SEARCH_HISTORY_ITEM, {
|
||||
onCompleted: () => {
|
||||
refetchHistory();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка удаления истории:', error);
|
||||
alert('Ошибка при удалении из истории');
|
||||
}
|
||||
});
|
||||
|
||||
const cars: UserVehicle[] = vehiclesData?.userVehicles || [];
|
||||
const searchHistory: VehicleSearchHistory[] = historyData?.vehicleSearchHistory || [];
|
||||
|
||||
const handleAddCar = () => {
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleSaveCar = async () => {
|
||||
if (!newCar.vin?.trim()) {
|
||||
alert('Введите VIN номер');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newCar.name?.trim()) {
|
||||
alert('Введите название автомобиля');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createVehicle({
|
||||
variables: {
|
||||
input: newCar
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения автомобиля:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setNewCar({ name: '', vin: '', comment: '' });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleDeleteCar = async (carId: string) => {
|
||||
if (confirm('Вы уверены, что хотите удалить этот автомобиль?')) {
|
||||
try {
|
||||
await deleteVehicle({
|
||||
variables: { id: carId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления автомобиля:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFromHistory = async (historyCar: VehicleSearchHistory) => {
|
||||
try {
|
||||
await addFromSearch({
|
||||
variables: {
|
||||
vin: historyCar.vin,
|
||||
comment: ''
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления из истории:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromHistory = async (historyId: string) => {
|
||||
try {
|
||||
await deleteHistoryItem({
|
||||
variables: { id: historyId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления истории:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
|
||||
{/* Хлебные крошки */}
|
||||
<section className="breadcrumbs">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="breadcrumb-wrapper">
|
||||
<a href="/" className="breadcrumb-link">Главная</a>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<a href="/profile" className="breadcrumb-link">Личный кабинет</a>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<span className="breadcrumb-current">Гараж</span>
|
||||
</div>
|
||||
<h1 className="profile-title">Гараж</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Основной контент */}
|
||||
<section className="profile-content">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="profile-layout">
|
||||
|
||||
{/* Боковое меню */}
|
||||
<ProfileSidebar activeItem="garage" />
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="profile-main">
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="search-section">
|
||||
<div className="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по гаражу"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<div className="search-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M19 19L14.65 14.65" stroke="#8893A1" strokeWidth="2" strokeLinecap="round"/>
|
||||
<circle cx="9" cy="9" r="7" stroke="#8893A1" strokeWidth="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Мои автомобили */}
|
||||
<div className="garage-section">
|
||||
<h2 className="section-title">Мои автомобили</h2>
|
||||
<div className="cars-grid">
|
||||
{cars.map((car) => (
|
||||
<div key={car.id} className="car-card">
|
||||
<div className="car-info">
|
||||
<div className="car-details">
|
||||
<h3 className="car-name">{car.name}</h3>
|
||||
<p className="car-vin">{car.vin}</p>
|
||||
</div>
|
||||
{car.comment && (
|
||||
<div className="car-comment-display">
|
||||
{car.comment}
|
||||
</div>
|
||||
)}
|
||||
<div className="car-actions">
|
||||
<button
|
||||
onClick={() => handleDeleteCar(car.id)}
|
||||
className="action-button delete"
|
||||
>
|
||||
<svg width="18" height="16" viewBox="0 0 18 16" fill="none">
|
||||
<path d="M2 4H16M7 4V2H11V4M3 4L4 14H14L15 4H3Z" stroke="#D0D0D0" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
<button className="action-button expand">
|
||||
Развернуть
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 4L7 9L12 4" stroke="#000814" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Форма добавления автомобиля */}
|
||||
{showAddForm && (
|
||||
<div className="add-car-form">
|
||||
<h3 className="form-title">Добавить авто в гараж</h3>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCar.name}
|
||||
onChange={(e) => setNewCar({...newCar, name: e.target.value})}
|
||||
placeholder="Название автомобиля"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">VIN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCar.vin || ''}
|
||||
onChange={(e) => setNewCar({...newCar, vin: e.target.value})}
|
||||
placeholder="VIN"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Комментарий</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCar.comment || ''}
|
||||
onChange={(e) => setNewCar({...newCar, comment: e.target.value})}
|
||||
placeholder="Комментарий"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button onClick={handleSaveCar} className="btn-primary">
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={handleCancelAdd} className="btn-secondary">
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm && (
|
||||
<button onClick={handleAddCar} className="btn-primary add-car-btn">
|
||||
Добавить авто
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История поиска */}
|
||||
<div className="garage-section">
|
||||
<h2 className="section-title">Ранее вы искали</h2>
|
||||
<div className="history-grid">
|
||||
{searchHistory.map((item) => (
|
||||
<div key={item.id} className="history-card">
|
||||
<div className="history-info">
|
||||
<div className="car-details">
|
||||
<h3 className="car-name-history">{item.brand && item.model ? `${item.brand} ${item.model}` : 'Неизвестный автомобиль'}</h3>
|
||||
<p className="car-vin">{item.vin}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddFromHistory(item)}
|
||||
className="action-button add"
|
||||
>
|
||||
<svg width="18" height="16" viewBox="0 0 18 16" fill="none">
|
||||
<path d="M9 3V13M3 8H15" stroke="#D0D0D0" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
Добавить в гараж
|
||||
</button>
|
||||
<div className="history-meta">
|
||||
<span className="search-date">{item.searchDate}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteFromHistory(item.id)}
|
||||
className="action-button delete small"
|
||||
>
|
||||
<svg width="18" height="16" viewBox="0 0 18 16" fill="none">
|
||||
<path d="M2 4H16M7 4V2H11V4M3 4L4 14H14L15 4H3Z" stroke="#D0D0D0" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style jsx>{`
|
||||
.page-wrapper {
|
||||
background-color: #F5F8FB;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
background: white;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding: 40px 0 60px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px 50px 12px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 16px;
|
||||
color: #000814;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #8893A1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.garage-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 30px 0;
|
||||
}
|
||||
|
||||
.cars-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.car-card {
|
||||
background: #F5F8FB;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.car-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.car-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.car-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-name-history {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-vin {
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.car-comment-display {
|
||||
padding: 6px 14px;
|
||||
background: white;
|
||||
border: 1px solid #F0F0F0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #D0D0D0;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.car-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.action-button:hover svg path {
|
||||
stroke: #EC1C24;
|
||||
}
|
||||
|
||||
.action-button.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-car-form {
|
||||
border-top: 1px solid #E6EDF6;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 16px 24px;
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #EC1C24;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 14px 20px;
|
||||
background: #EC1C24;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d91920;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 14px 20px;
|
||||
background: transparent;
|
||||
color: #000814;
|
||||
border: 1px solid #EC1C24;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.add-car-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: #F5F8FB;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search-date {
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-layout {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.car-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.car-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
height: auto;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileGaragePage;
|
60
src/pages/profile-history.tsx
Normal file
60
src/pages/profile-history.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileHistoryMain from '@/components/profile/ProfileHistoryMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
const ProfileHistoryPage = () => {
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const [menuHeight, setMenuHeight] = React.useState<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const updateHeight = () => {
|
||||
setMenuHeight(menuRef.current?.offsetHeight);
|
||||
};
|
||||
updateHeight();
|
||||
const observer = new window.ResizeObserver(updateHeight);
|
||||
observer.observe(menuRef.current);
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>ProfileHistory</title>
|
||||
<meta content="ProfileHistory" property="og:title" />
|
||||
<meta content="ProfileHistory" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5 h-full flex-1">
|
||||
<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">
|
||||
<LKMenu ref={menuRef} />
|
||||
<ProfileHistoryMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ProfileHistoryPage;
|
31
src/pages/profile-notification.tsx
Normal file
31
src/pages/profile-notification.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ProfileSidebar from '@/components/ProfileSidebar';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import NotificationMane from '@/components/profile/NotificationMane';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
|
||||
|
||||
const ProfileNotificationPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px]">
|
||||
<LKMenu />
|
||||
<NotificationMane />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileNotificationPage;
|
42
src/pages/profile-orders.tsx
Normal file
42
src/pages/profile-orders.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ProfileSidebar from '@/components/ProfileSidebar';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileOrdersMain from '@/components/profile/ProfileOrdersMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
|
||||
const ProfileOrdersPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileOrders</title>
|
||||
<meta content="ProfileOrders" property="og:title" />
|
||||
<meta content="ProfileOrders" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileOrdersMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileOrdersPage;
|
40
src/pages/profile-req.tsx
Normal file
40
src/pages/profile-req.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileRequisitiesMain from '@/components/profile/ProfileRequisitiesMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
const ProfileRequisitiesPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Head>
|
||||
<title>ProfileRequisities</title>
|
||||
<meta content="ProfileRequisities" property="og:title" />
|
||||
<meta content="ProfileRequisities" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileRequisitiesMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileRequisitiesPage;
|
1475
src/pages/profile-requisites.tsx
Normal file
1475
src/pages/profile-requisites.tsx
Normal file
@ -0,0 +1,1475 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery, useMutation } from '@apollo/client';
|
||||
import Header from '../components/Header';
|
||||
import ProfileSidebar from '../components/ProfileSidebar';
|
||||
import {
|
||||
GET_CLIENT_ME,
|
||||
CREATE_CLIENT_LEGAL_ENTITY,
|
||||
UPDATE_CLIENT_LEGAL_ENTITY,
|
||||
DELETE_CLIENT_LEGAL_ENTITY,
|
||||
CREATE_CLIENT_BANK_DETAILS,
|
||||
UPDATE_CLIENT_BANK_DETAILS,
|
||||
DELETE_CLIENT_BANK_DETAILS
|
||||
} from '../lib/graphql';
|
||||
|
||||
interface LegalEntity {
|
||||
id: string;
|
||||
shortName: string;
|
||||
fullName: string;
|
||||
form: string;
|
||||
legalAddress: string;
|
||||
actualAddress?: string;
|
||||
taxSystem: string;
|
||||
responsiblePhone?: string;
|
||||
responsiblePosition?: string;
|
||||
responsibleName?: string;
|
||||
accountant?: string;
|
||||
signatory?: string;
|
||||
registrationReasonCode?: string;
|
||||
ogrn?: string;
|
||||
inn: string;
|
||||
vatPercent: number;
|
||||
bankDetails?: BankDetail[];
|
||||
}
|
||||
|
||||
interface BankDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
accountNumber: string;
|
||||
bankName: string;
|
||||
bik: string;
|
||||
correspondentAccount: string;
|
||||
}
|
||||
|
||||
interface NewLegalEntity {
|
||||
shortName: string;
|
||||
fullName: string;
|
||||
form: string;
|
||||
legalAddress: string;
|
||||
actualAddress?: string;
|
||||
taxSystem: string;
|
||||
responsiblePhone?: string;
|
||||
responsiblePosition?: string;
|
||||
responsibleName?: string;
|
||||
accountant?: string;
|
||||
signatory?: string;
|
||||
registrationReasonCode?: string;
|
||||
ogrn?: string;
|
||||
inn: string;
|
||||
vatPercent: number;
|
||||
}
|
||||
|
||||
interface NewBankDetail {
|
||||
name: string;
|
||||
accountNumber: string;
|
||||
bankName: string;
|
||||
bik: string;
|
||||
correspondentAccount: string;
|
||||
}
|
||||
|
||||
const ProfileRequisites: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [showAddModal, setShowAddModal] = useState<boolean>(false);
|
||||
const [showBankModal, setShowBankModal] = useState<boolean>(false);
|
||||
const [editingEntity, setEditingEntity] = useState<LegalEntity | null>(null);
|
||||
const [editingBankDetail, setEditingBankDetail] = useState<BankDetail | null>(null);
|
||||
const [selectedLegalEntityId, setSelectedLegalEntityId] = useState<string>('');
|
||||
const [legalEntities, setLegalEntities] = useState<LegalEntity[]>([]);
|
||||
|
||||
const [newEntity, setNewEntity] = useState<NewLegalEntity>({
|
||||
shortName: '',
|
||||
fullName: '',
|
||||
form: 'ООО',
|
||||
legalAddress: '',
|
||||
actualAddress: '',
|
||||
taxSystem: 'УСН',
|
||||
responsiblePhone: '',
|
||||
responsiblePosition: '',
|
||||
responsibleName: '',
|
||||
accountant: '',
|
||||
signatory: '',
|
||||
registrationReasonCode: '',
|
||||
ogrn: '',
|
||||
inn: '',
|
||||
vatPercent: 20
|
||||
});
|
||||
|
||||
const [newBankDetail, setNewBankDetail] = useState<NewBankDetail>({
|
||||
name: '',
|
||||
accountNumber: '',
|
||||
bankName: '',
|
||||
bik: '',
|
||||
correspondentAccount: ''
|
||||
});
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data: clientData, loading: clientLoading, error: clientError, refetch } = useQuery(GET_CLIENT_ME, {
|
||||
skip: !isAuthenticated,
|
||||
onCompleted: (data) => {
|
||||
console.log('GET_CLIENT_ME onCompleted:', data);
|
||||
if (data?.clientMe?.legalEntities) {
|
||||
setLegalEntities(data.clientMe.legalEntities);
|
||||
console.log('Юридические лица загружены:', data.clientMe.legalEntities);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('GET_CLIENT_ME onError:', error);
|
||||
console.error('Детали ошибки GET_CLIENT_ME:', JSON.stringify(error, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
const [createLegalEntity] = useMutation(CREATE_CLIENT_LEGAL_ENTITY);
|
||||
const [updateLegalEntity] = useMutation(UPDATE_CLIENT_LEGAL_ENTITY);
|
||||
const [deleteLegalEntity] = useMutation(DELETE_CLIENT_LEGAL_ENTITY);
|
||||
const [createBankDetails] = useMutation(CREATE_CLIENT_BANK_DETAILS);
|
||||
const [updateBankDetails] = useMutation(UPDATE_CLIENT_BANK_DETAILS);
|
||||
const [deleteBankDetails] = useMutation(DELETE_CLIENT_BANK_DETAILS);
|
||||
|
||||
// Проверка авторизации
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('userData');
|
||||
console.log('ProfileRequisites: проверяем userData в localStorage:', userData);
|
||||
|
||||
if (!userData) {
|
||||
console.log('ProfileRequisites: userData не найден, перенаправляем на главную');
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const user = JSON.parse(userData);
|
||||
console.log('ProfileRequisites: parsed user data:', user);
|
||||
|
||||
if (!user.id) {
|
||||
console.log('ProfileRequisites: user.id не найден, перенаправляем на главную');
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('ProfileRequisites: авторизация успешна, user.id:', user.id);
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
console.error('ProfileRequisites: ошибка парсинга userData:', error);
|
||||
router.push('/');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Обработчики для юридических лиц
|
||||
const handleAddEntity = () => {
|
||||
setEditingEntity(null);
|
||||
setNewEntity({
|
||||
shortName: '',
|
||||
fullName: '',
|
||||
form: 'ООО',
|
||||
legalAddress: '',
|
||||
actualAddress: '',
|
||||
taxSystem: 'УСН',
|
||||
responsiblePhone: '',
|
||||
responsiblePosition: '',
|
||||
responsibleName: '',
|
||||
accountant: '',
|
||||
signatory: '',
|
||||
registrationReasonCode: '',
|
||||
ogrn: '',
|
||||
inn: '',
|
||||
vatPercent: 20
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleEditEntity = (entity: LegalEntity) => {
|
||||
setEditingEntity(entity);
|
||||
setNewEntity({
|
||||
shortName: entity.shortName,
|
||||
fullName: entity.fullName,
|
||||
form: entity.form,
|
||||
legalAddress: entity.legalAddress,
|
||||
actualAddress: entity.actualAddress || '',
|
||||
taxSystem: entity.taxSystem,
|
||||
responsiblePhone: entity.responsiblePhone || '',
|
||||
responsiblePosition: entity.responsiblePosition || '',
|
||||
responsibleName: entity.responsibleName || '',
|
||||
accountant: entity.accountant || '',
|
||||
signatory: entity.signatory || '',
|
||||
registrationReasonCode: entity.registrationReasonCode || '',
|
||||
ogrn: entity.ogrn || '',
|
||||
inn: entity.inn,
|
||||
vatPercent: entity.vatPercent
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleSaveEntity = async () => {
|
||||
try {
|
||||
console.log('handleSaveEntity: начало сохранения', { editingEntity, newEntity });
|
||||
|
||||
if (editingEntity) {
|
||||
// Обновление существующего юр. лица
|
||||
console.log('handleSaveEntity: обновление юр. лица', editingEntity.id);
|
||||
const { data } = await updateLegalEntity({
|
||||
variables: {
|
||||
id: editingEntity.id,
|
||||
input: newEntity
|
||||
}
|
||||
});
|
||||
|
||||
console.log('handleSaveEntity: результат обновления', data);
|
||||
|
||||
if (data?.updateClientLegalEntity) {
|
||||
setLegalEntities(prev =>
|
||||
prev.map(entity =>
|
||||
entity.id === editingEntity.id ? data.updateClientLegalEntity : entity
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Создание нового юр. лица
|
||||
console.log('handleSaveEntity: создание нового юр. лица');
|
||||
const { data } = await createLegalEntity({
|
||||
variables: {
|
||||
input: newEntity
|
||||
}
|
||||
});
|
||||
|
||||
console.log('handleSaveEntity: результат создания', data);
|
||||
|
||||
if (data?.createClientLegalEntityMe) {
|
||||
setLegalEntities(prev => [...prev, data.createClientLegalEntityMe]);
|
||||
}
|
||||
}
|
||||
|
||||
setShowAddModal(false);
|
||||
setEditingEntity(null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения юридического лица:', error);
|
||||
console.error('Детали ошибки:', JSON.stringify(error, null, 2));
|
||||
alert('Ошибка при сохранении данных: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async (entityId: string) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить это юридическое лицо?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteLegalEntity({
|
||||
variables: { id: entityId }
|
||||
});
|
||||
|
||||
setLegalEntities(prev => prev.filter(entity => entity.id !== entityId));
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления юридического лица:', error);
|
||||
alert('Ошибка при удалении');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики для банковских реквизитов
|
||||
const handleAddBankDetail = (legalEntityId: string) => {
|
||||
setSelectedLegalEntityId(legalEntityId);
|
||||
setEditingBankDetail(null);
|
||||
setNewBankDetail({
|
||||
name: '',
|
||||
accountNumber: '',
|
||||
bankName: '',
|
||||
bik: '',
|
||||
correspondentAccount: ''
|
||||
});
|
||||
setShowBankModal(true);
|
||||
};
|
||||
|
||||
const handleEditBankDetail = (bankDetail: BankDetail, legalEntityId: string) => {
|
||||
setSelectedLegalEntityId(legalEntityId);
|
||||
setEditingBankDetail(bankDetail);
|
||||
setNewBankDetail({
|
||||
name: bankDetail.name,
|
||||
accountNumber: bankDetail.accountNumber,
|
||||
bankName: bankDetail.bankName,
|
||||
bik: bankDetail.bik,
|
||||
correspondentAccount: bankDetail.correspondentAccount
|
||||
});
|
||||
setShowBankModal(true);
|
||||
};
|
||||
|
||||
const handleSaveBankDetail = async () => {
|
||||
try {
|
||||
if (editingBankDetail) {
|
||||
// Обновление существующих банковских реквизитов
|
||||
const { data } = await updateBankDetails({
|
||||
variables: {
|
||||
id: editingBankDetail.id,
|
||||
input: newBankDetail
|
||||
}
|
||||
});
|
||||
|
||||
if (data?.updateClientBankDetails) {
|
||||
// Обновляем локальное состояние
|
||||
setLegalEntities(prev =>
|
||||
prev.map(entity =>
|
||||
entity.id === selectedLegalEntityId
|
||||
? {
|
||||
...entity,
|
||||
bankDetails: entity.bankDetails?.map(bd =>
|
||||
bd.id === editingBankDetail.id ? data.updateClientBankDetails : bd
|
||||
) || []
|
||||
}
|
||||
: entity
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Создание новых банковских реквизитов
|
||||
const { data } = await createBankDetails({
|
||||
variables: {
|
||||
legalEntityId: selectedLegalEntityId,
|
||||
input: newBankDetail
|
||||
}
|
||||
});
|
||||
|
||||
if (data?.createClientBankDetails) {
|
||||
// Добавляем новые реквизиты к соответствующему юр. лицу
|
||||
setLegalEntities(prev =>
|
||||
prev.map(entity =>
|
||||
entity.id === selectedLegalEntityId
|
||||
? {
|
||||
...entity,
|
||||
bankDetails: [...(entity.bankDetails || []), data.createClientBankDetails]
|
||||
}
|
||||
: entity
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setShowBankModal(false);
|
||||
setEditingBankDetail(null);
|
||||
setSelectedLegalEntityId('');
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения банковских реквизитов:', error);
|
||||
alert('Ошибка при сохранении данных');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBankDetail = async (bankDetailId: string, legalEntityId: string) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эти банковские реквизиты?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteBankDetails({
|
||||
variables: { id: bankDetailId }
|
||||
});
|
||||
|
||||
// Удаляем банковские реквизиты из локального состояния
|
||||
setLegalEntities(prev =>
|
||||
prev.map(entity =>
|
||||
entity.id === legalEntityId
|
||||
? {
|
||||
...entity,
|
||||
bankDetails: entity.bankDetails?.filter(bd => bd.id !== bankDetailId) || []
|
||||
}
|
||||
: entity
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления банковских реквизитов:', error);
|
||||
alert('Ошибка при удалении');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof NewLegalEntity, value: string | number) => {
|
||||
setNewEntity(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBankInputChange = (field: keyof NewBankDetail, value: string) => {
|
||||
setNewBankDetail(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (clientLoading) {
|
||||
return (
|
||||
<div className="loading-wrapper">
|
||||
<div className="loading-text">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
|
||||
{/* Хлебные крошки */}
|
||||
<section className="breadcrumbs">
|
||||
<div className="container">
|
||||
<div className="breadcrumb-wrapper">
|
||||
<a href="/" className="breadcrumb-link">Главная</a>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<a href="/profile-settings" className="breadcrumb-link">Личный кабинет</a>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<span className="breadcrumb-current">Реквизиты</span>
|
||||
</div>
|
||||
<h1 className="profile-title">Реквизиты</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Основной контент */}
|
||||
<section className="profile-section">
|
||||
<div className="container">
|
||||
<div className="profile-layout">
|
||||
<ProfileSidebar activeItem="requisites" />
|
||||
|
||||
<div className="profile-content">
|
||||
{/* Заголовок и кнопка добавления */}
|
||||
<div className="requisites-header">
|
||||
<div className="header-content">
|
||||
<h2 className="section-title">Юридические лица</h2>
|
||||
<p className="section-description">
|
||||
Управляйте информацией о ваших юридических лицах и банковских реквизитах
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddEntity}
|
||||
className="btn-add"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4V16M4 10H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
Добавить юр. лицо
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список юридических лиц */}
|
||||
<div className="requisites-list">
|
||||
{legalEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="16" width="48" height="32" rx="4" stroke="#D0D0D0" strokeWidth="2"/>
|
||||
<path d="M16 24H48M16 32H40M16 40H32" stroke="#D0D0D0" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="empty-title">Нет добавленных юридических лиц</h3>
|
||||
<p className="empty-description">
|
||||
Добавьте информацию о ваших юридических лицах для оформления документов
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddEntity}
|
||||
className="btn-primary"
|
||||
>
|
||||
Добавить первое юр. лицо
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
legalEntities.map((entity) => (
|
||||
<div key={entity.id} className="entity-card">
|
||||
<div className="entity-main">
|
||||
<div className="entity-info">
|
||||
<h3 className="entity-name">{entity.shortName}</h3>
|
||||
<p className="entity-full-name">{entity.fullName}</p>
|
||||
<div className="entity-details">
|
||||
<span className="entity-detail">
|
||||
<strong>ИНН:</strong> {entity.inn}
|
||||
</span>
|
||||
<span className="entity-detail">
|
||||
<strong>Форма:</strong> {entity.form}
|
||||
</span>
|
||||
<span className="entity-detail">
|
||||
<strong>Налоговая система:</strong> {entity.taxSystem}
|
||||
</span>
|
||||
</div>
|
||||
<p className="entity-address">
|
||||
<strong>Юридический адрес:</strong> {entity.legalAddress}
|
||||
</p>
|
||||
</div>
|
||||
<div className="entity-actions">
|
||||
<button
|
||||
onClick={() => handleEditEntity(entity)}
|
||||
className="action-btn edit-btn"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.334 2.00004C11.5091 1.82494 11.7169 1.68605 11.9457 1.59129C12.1745 1.49653 12.4197 1.44775 12.6673 1.44775C12.9149 1.44775 13.1601 1.49653 13.3889 1.59129C13.6177 1.68605 13.8255 1.82494 14.0007 2.00004C14.1758 2.17513 14.3147 2.383 14.4094 2.61178C14.5042 2.84055 14.553 3.08575 14.553 3.33337C14.553 3.58099 14.5042 3.82619 14.4094 4.05497C14.3147 4.28374 14.1758 4.49161 14.0007 4.66671L5.00065 13.6667L1.33398 14.6667L2.33398 11L11.334 2.00004Z" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteEntity(entity.id)}
|
||||
className="action-btn delete-btn"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4H14M12.6667 4V13.3333C12.6667 13.687 12.5262 14.0261 12.2761 14.2761C12.0261 14.5262 11.687 14.6667 11.3333 14.6667H4.66667C4.31304 14.6667 3.97391 14.5262 3.72386 14.2761C3.47381 14.0261 3.33333 13.687 3.33333 13.3333V4M5.33333 4V2.66667C5.33333 2.31304 5.47381 1.97391 5.72386 1.72386C5.97391 1.47381 6.31304 1.33333 6.66667 1.33333H9.33333C9.687 1.33333 10.0261 1.47381 10.2761 1.72386C10.5262 1.97391 10.6667 2.31304 10.6667 2.66667V4" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6.66699 7.33337V11.3334M9.33366 7.33337V11.3334" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Банковские реквизиты */}
|
||||
<div className="bank-details">
|
||||
<div className="bank-details-header">
|
||||
<h4 className="bank-details-title">Банковские реквизиты</h4>
|
||||
<button
|
||||
onClick={() => handleAddBankDetail(entity.id)}
|
||||
className="btn-add small"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 3.2V12.8M3.2 8H12.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
Добавить реквизиты
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entity.bankDetails && entity.bankDetails.length > 0 ? (
|
||||
<div className="bank-details-list">
|
||||
{entity.bankDetails.map((bankDetail) => (
|
||||
<div key={bankDetail.id} className="bank-detail-item">
|
||||
<div className="bank-info">
|
||||
<p className="bank-name">{bankDetail.name}</p>
|
||||
<p className="bank-account">Р/с: {bankDetail.accountNumber}</p>
|
||||
<p className="bank-bik">БИК: {bankDetail.bik}</p>
|
||||
<p className="bank-correspondent">К/с: {bankDetail.correspondentAccount}</p>
|
||||
<p className="bank-bank-name">Банк: {bankDetail.bankName}</p>
|
||||
</div>
|
||||
<div className="bank-actions">
|
||||
<button
|
||||
onClick={() => handleEditBankDetail(bankDetail, entity.id)}
|
||||
className="action-btn edit-btn"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.334 2.00004C11.5091 1.82494 11.7169 1.68605 11.9457 1.59129C12.1745 1.49653 12.4197 1.44775 12.6673 1.44775C12.9149 1.44775 13.1601 1.49653 13.3889 1.59129C13.6177 1.68605 13.8255 1.82494 14.0007 2.00004C14.1758 2.17513 14.3147 2.383 14.4094 2.61178C14.5042 2.84055 14.553 3.08575 14.553 3.33337C14.553 3.58099 14.5042 3.82619 14.4094 4.05497C14.3147 4.28374 14.1758 4.49161 14.0007 4.66671L5.00065 13.6667L1.33398 14.6667L2.33398 11L11.334 2.00004Z" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBankDetail(bankDetail.id, entity.id)}
|
||||
className="action-btn delete-btn"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4H14M12.6667 4V13.3333C12.6667 13.687 12.5262 14.0261 12.2761 14.2761C12.0261 14.5262 11.687 14.6667 11.3333 14.6667H4.66667C4.31304 14.6667 3.97391 14.5262 3.72386 14.2761C3.47381 14.0261 3.33333 13.687 3.33333 13.3333V4M5.33333 4V2.66667C5.33333 2.31304 5.47381 1.97391 5.72386 1.72386C5.97391 1.47381 6.31304 1.33333 6.66667 1.33333H9.33333C9.687 1.33333 10.0261 1.47381 10.2761 1.72386C10.5262 1.97391 10.6667 2.31304 10.6667 2.66667V4" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6.66699 7.33337V11.3334M9.33366 7.33337V11.3334" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bank-details-empty">
|
||||
<p className="empty-text">Банковские реквизиты не добавлены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Модальное окно добавления/редактирования */}
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingEntity ? 'Редактировать юридическое лицо' : 'Добавить юридическое лицо'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="modal-close"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-form">
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Краткое наименование *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.shortName}
|
||||
onChange={(e) => handleInputChange('shortName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="ООО «Компания»"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Форма *</label>
|
||||
<select
|
||||
value={newEntity.form}
|
||||
onChange={(e) => handleInputChange('form', e.target.value)}
|
||||
className="form-select"
|
||||
required
|
||||
>
|
||||
<option value="ООО">ООО</option>
|
||||
<option value="ИП">ИП</option>
|
||||
<option value="АО">АО</option>
|
||||
<option value="ПАО">ПАО</option>
|
||||
<option value="ЗАО">ЗАО</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Полное наименование *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.fullName}
|
||||
onChange={(e) => handleInputChange('fullName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Общество с ограниченной ответственностью «Компания»"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">ИНН *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.inn}
|
||||
onChange={(e) => handleInputChange('inn', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="1234567890"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">ОГРН</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.ogrn}
|
||||
onChange={(e) => handleInputChange('ogrn', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="1234567890123"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Юридический адрес *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.legalAddress}
|
||||
onChange={(e) => handleInputChange('legalAddress', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="г. Москва, ул. Примерная, д. 1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Фактический адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.actualAddress}
|
||||
onChange={(e) => handleInputChange('actualAddress', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="г. Москва, ул. Примерная, д. 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Налоговая система *</label>
|
||||
<select
|
||||
value={newEntity.taxSystem}
|
||||
onChange={(e) => handleInputChange('taxSystem', e.target.value)}
|
||||
className="form-select"
|
||||
required
|
||||
>
|
||||
<option value="УСН">УСН</option>
|
||||
<option value="ОСНО">ОСНО</option>
|
||||
<option value="ЕНВД">ЕНВД</option>
|
||||
<option value="ПСН">ПСН</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">НДС (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newEntity.vatPercent}
|
||||
onChange={(e) => handleInputChange('vatPercent', Number(e.target.value))}
|
||||
className="form-input"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">ФИО ответственного</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.responsibleName}
|
||||
onChange={(e) => handleInputChange('responsibleName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Должность ответственного</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.responsiblePosition}
|
||||
onChange={(e) => handleInputChange('responsiblePosition', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Генеральный директор"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Телефон ответственного</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={newEntity.responsiblePhone}
|
||||
onChange={(e) => handleInputChange('responsiblePhone', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Бухгалтер</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.accountant}
|
||||
onChange={(e) => handleInputChange('accountant', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Петрова Анна Сергеевна"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Подписант</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.signatory}
|
||||
onChange={(e) => handleInputChange('signatory', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Иванов И.И."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">КПП</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.registrationReasonCode}
|
||||
onChange={(e) => handleInputChange('registrationReasonCode', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEntity}
|
||||
className="btn-primary"
|
||||
>
|
||||
{editingEntity ? 'Сохранить изменения' : 'Добавить юр. лицо'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно добавления/редактирования банковских реквизитов */}
|
||||
{showBankModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowBankModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingBankDetail ? 'Редактировать банковские реквизиты' : 'Добавить банковские реквизиты'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowBankModal(false)}
|
||||
className="modal-close"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-form">
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Название счета *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBankDetail.name}
|
||||
onChange={(e) => handleBankInputChange('name', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Расчетный счет"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">№ Расчетного счета *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBankDetail.accountNumber}
|
||||
onChange={(e) => handleBankInputChange('accountNumber', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="40702810000000000000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">БИК *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBankDetail.bik}
|
||||
onChange={(e) => handleBankInputChange('bik', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="044525225"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Наименование банка *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBankDetail.bankName}
|
||||
onChange={(e) => handleBankInputChange('bankName', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="ПАО СБЕРБАНК"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Корреспондентский счет *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBankDetail.correspondentAccount}
|
||||
onChange={(e) => handleBankInputChange('correspondentAccount', e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="30101810400000000225"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
onClick={() => setShowBankModal(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveBankDetail}
|
||||
className="btn-primary"
|
||||
>
|
||||
{editingBankDetail ? 'Сохранить изменения' : 'Добавить реквизиты'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.page-wrapper {
|
||||
background-color: #F5F8FB;
|
||||
min-height: 100vh;
|
||||
font-family: 'Onest', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
background: white;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1660px;
|
||||
margin: 0 auto;
|
||||
padding: 0 130px;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 40px 0 60px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.requisites-header {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 16px;
|
||||
color: #8E9AAC;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #EC1C24;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #d91920;
|
||||
}
|
||||
|
||||
.requisites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 60px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin: 0 auto 24px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #000814;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 16px;
|
||||
color: #8E9AAC;
|
||||
margin: 0 0 32px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #EC1C24;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d91920;
|
||||
}
|
||||
|
||||
.entity-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.entity-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.entity-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.entity-full-name {
|
||||
font-size: 16px;
|
||||
color: #424F60;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.entity-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.entity-detail {
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
.entity-detail strong {
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
.entity-address {
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.entity-address strong {
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
.entity-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
border-color: #EC1C24;
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #DC2626;
|
||||
border-color: #DC2626;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #DC2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bank-details {
|
||||
border-top: 1px solid #E6EDF6;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.bank-details-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bank-details-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-add.small {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bank-details-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bank-details-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #F5F8FB;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #D0D0D0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #8E9AAC;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bank-detail-item {
|
||||
background: #F5F8FB;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bank-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bank-info p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
.bank-name {
|
||||
font-weight: 600;
|
||||
color: #000814 !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.bank-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 30px 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #8E9AAC;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #000814;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
font-family: 'Onest', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
border-color: #EC1C24;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23424F60' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 0 30px 30px;
|
||||
border-top: 1px solid #E6EDF6;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #424F60;
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: #424F60;
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #F5F8FB;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 18px;
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.requisites-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.entity-main {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.entity-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-form,
|
||||
.modal-actions {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileRequisites;
|
40
src/pages/profile-set.tsx
Normal file
40
src/pages/profile-set.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import CatalogSubscribe from '@/components/CatalogSubscribe';
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import LKMenu from '@/components/LKMenu';
|
||||
import ProfileSettingsMain from '@/components/profile/ProfileSettingsMain';
|
||||
import ProfileInfo from '@/components/profile/ProfileInfo';
|
||||
import Head from "next/head";
|
||||
|
||||
|
||||
|
||||
const ProfileSettingsPage = () => {
|
||||
return (
|
||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
||||
<Head>
|
||||
<title>ProfileHistory</title>
|
||||
<meta content="ProfileSettings" property="og:title" />
|
||||
<meta content="ProfileSettings" property="twitter:title" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu />
|
||||
<ProfileSettingsMain />
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettingsPage;
|
1165
src/pages/profile-settings.tsx
Normal file
1165
src/pages/profile-settings.tsx
Normal file
@ -0,0 +1,1165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery, useMutation } from '@apollo/client';
|
||||
import Header from '../components/Header';
|
||||
import ProfileSidebar from '../components/ProfileSidebar';
|
||||
import {
|
||||
GET_CLIENT_ME,
|
||||
UPDATE_CLIENT_PERSONAL_DATA,
|
||||
CREATE_CLIENT_LEGAL_ENTITY,
|
||||
UPDATE_CLIENT_LEGAL_ENTITY,
|
||||
DELETE_CLIENT_LEGAL_ENTITY
|
||||
} from '../lib/graphql';
|
||||
|
||||
interface PersonalData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
emailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
}
|
||||
|
||||
interface LegalEntity {
|
||||
id: string;
|
||||
shortName: string;
|
||||
fullName: string;
|
||||
form: string;
|
||||
legalAddress: string;
|
||||
actualAddress?: string;
|
||||
taxSystem: string;
|
||||
responsiblePhone?: string;
|
||||
responsiblePosition?: string;
|
||||
responsibleName?: string;
|
||||
accountant?: string;
|
||||
signatory?: string;
|
||||
registrationReasonCode?: string;
|
||||
ogrn?: string;
|
||||
inn: string;
|
||||
vatPercent: number;
|
||||
}
|
||||
|
||||
interface NewLegalEntity {
|
||||
shortName: string;
|
||||
fullName: string;
|
||||
form: string;
|
||||
legalAddress: string;
|
||||
actualAddress?: string;
|
||||
taxSystem: string;
|
||||
responsiblePhone?: string;
|
||||
responsiblePosition?: string;
|
||||
responsibleName?: string;
|
||||
accountant?: string;
|
||||
signatory?: string;
|
||||
registrationReasonCode?: string;
|
||||
ogrn?: string;
|
||||
inn: string;
|
||||
vatPercent?: number;
|
||||
}
|
||||
|
||||
const ProfileSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [showAddModal, setShowAddModal] = useState<boolean>(false);
|
||||
const [editingEntity, setEditingEntity] = useState<LegalEntity | null>(null);
|
||||
const [clientId, setClientId] = useState<string>('');
|
||||
|
||||
const [personalData, setPersonalData] = useState<PersonalData>({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
pushNotifications: false
|
||||
});
|
||||
|
||||
const [newEntity, setNewEntity] = useState<NewLegalEntity>({
|
||||
shortName: '',
|
||||
fullName: '',
|
||||
form: '',
|
||||
legalAddress: '',
|
||||
actualAddress: '',
|
||||
taxSystem: '',
|
||||
responsiblePhone: '',
|
||||
responsiblePosition: '',
|
||||
responsibleName: '',
|
||||
accountant: '',
|
||||
signatory: '',
|
||||
registrationReasonCode: '',
|
||||
ogrn: '',
|
||||
inn: '',
|
||||
vatPercent: 0
|
||||
});
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data: clientData, loading: clientLoading, error: clientError, refetch } = useQuery(GET_CLIENT_ME, {
|
||||
skip: !isAuthenticated,
|
||||
onCompleted: (data) => {
|
||||
console.log('GET_CLIENT_ME onCompleted:', data);
|
||||
if (data?.clientMe) {
|
||||
setPersonalData({
|
||||
name: data.clientMe.name || '',
|
||||
phone: data.clientMe.phone || '',
|
||||
email: data.clientMe.email || '',
|
||||
emailNotifications: data.clientMe.emailNotifications ?? true,
|
||||
smsNotifications: data.clientMe.smsNotifications ?? false,
|
||||
pushNotifications: data.clientMe.pushNotifications ?? false
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('GET_CLIENT_ME onError:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const [updatePersonalData] = useMutation(UPDATE_CLIENT_PERSONAL_DATA);
|
||||
const [createLegalEntity] = useMutation(CREATE_CLIENT_LEGAL_ENTITY);
|
||||
const [updateLegalEntity] = useMutation(UPDATE_CLIENT_LEGAL_ENTITY);
|
||||
const [deleteLegalEntity] = useMutation(DELETE_CLIENT_LEGAL_ENTITY);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const userData = localStorage.getItem('userData');
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData);
|
||||
setIsAuthenticated(true);
|
||||
setClientId(user.id);
|
||||
} else {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
const handlePersonalDataChange = (field: keyof PersonalData, value: string | boolean) => {
|
||||
setPersonalData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNewEntityChange = (field: keyof NewLegalEntity, value: string | number) => {
|
||||
setNewEntity(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSavePersonalData = async () => {
|
||||
try {
|
||||
await updatePersonalData({
|
||||
variables: {
|
||||
input: {
|
||||
type: 'INDIVIDUAL', // Добавляем обязательное поле
|
||||
name: personalData.name,
|
||||
email: personalData.email,
|
||||
phone: personalData.phone,
|
||||
emailNotifications: personalData.emailNotifications,
|
||||
smsNotifications: personalData.smsNotifications,
|
||||
pushNotifications: personalData.pushNotifications
|
||||
}
|
||||
}
|
||||
});
|
||||
alert('Персональные данные сохранены');
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения данных:', error);
|
||||
alert('Ошибка сохранения данных');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntity = async () => {
|
||||
if (!newEntity.shortName || !newEntity.inn) {
|
||||
alert('Заполните обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createLegalEntity({
|
||||
variables: {
|
||||
input: newEntity
|
||||
}
|
||||
});
|
||||
resetForm();
|
||||
setShowAddModal(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания юр. лица:', error);
|
||||
alert('Ошибка создания юридического лица');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewEntity({
|
||||
shortName: '',
|
||||
fullName: '',
|
||||
form: '',
|
||||
legalAddress: '',
|
||||
actualAddress: '',
|
||||
taxSystem: '',
|
||||
responsiblePhone: '',
|
||||
responsiblePosition: '',
|
||||
responsibleName: '',
|
||||
accountant: '',
|
||||
signatory: '',
|
||||
registrationReasonCode: '',
|
||||
ogrn: '',
|
||||
inn: '',
|
||||
vatPercent: 0
|
||||
});
|
||||
setEditingEntity(null);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async (id: string) => {
|
||||
if (window.confirm('Удалить юридическое лицо?')) {
|
||||
try {
|
||||
await deleteLegalEntity({
|
||||
variables: { id }
|
||||
});
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления юр. лица:', error);
|
||||
alert('Ошибка удаления юридического лица');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEntity = (entity: LegalEntity) => {
|
||||
setEditingEntity(entity);
|
||||
setNewEntity({
|
||||
shortName: entity.shortName,
|
||||
fullName: entity.fullName,
|
||||
form: entity.form,
|
||||
legalAddress: entity.legalAddress,
|
||||
actualAddress: entity.actualAddress || '',
|
||||
taxSystem: entity.taxSystem,
|
||||
responsiblePhone: entity.responsiblePhone || '',
|
||||
responsiblePosition: entity.responsiblePosition || '',
|
||||
responsibleName: entity.responsibleName || '',
|
||||
accountant: entity.accountant || '',
|
||||
signatory: entity.signatory || '',
|
||||
registrationReasonCode: entity.registrationReasonCode || '',
|
||||
ogrn: entity.ogrn || '',
|
||||
inn: entity.inn,
|
||||
vatPercent: entity.vatPercent
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleUpdateEntity = async () => {
|
||||
if (!editingEntity || !newEntity.shortName || !newEntity.inn) {
|
||||
alert('Заполните обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLegalEntity({
|
||||
variables: {
|
||||
id: editingEntity.id,
|
||||
input: newEntity
|
||||
}
|
||||
});
|
||||
resetForm();
|
||||
setShowAddModal(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления юр. лица:', error);
|
||||
alert('Ошибка обновления юридического лица');
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowAddModal(false);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
if (clientLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div className="loading-wrapper">
|
||||
<div className="loading-text">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clientError) {
|
||||
console.error('GraphQL Error:', clientError);
|
||||
return (
|
||||
<div className="error-wrapper">
|
||||
<div className="error-text">Ошибка загрузки данных: {clientError.message}</div>
|
||||
<button onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legalEntities = clientData?.clientMe?.legalEntities || [];
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
{/* Хлебные крошки */}
|
||||
<div className="breadcrumbs">
|
||||
<div className="container">
|
||||
<div className="breadcrumb-wrapper">
|
||||
<span className="breadcrumb-link">Главная</span>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<span className="breadcrumb-link">Личный кабинет</span>
|
||||
<span className="breadcrumb-separator">→</span>
|
||||
<span className="breadcrumb-current">Настройки аккаунта</span>
|
||||
</div>
|
||||
<h1 className="profile-title">
|
||||
Настройки аккаунта
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="profile-section">
|
||||
<div className="container">
|
||||
<div className="profile-layout">
|
||||
<ProfileSidebar activeItem="Настройки" />
|
||||
|
||||
<div className="profile-content">
|
||||
{/* Персональные данные */}
|
||||
<div className="settings-card">
|
||||
<h2 className="card-title">
|
||||
Персональные данные
|
||||
</h2>
|
||||
|
||||
<div className="personal-form">
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={personalData.name}
|
||||
onChange={(e) => handlePersonalDataChange('name', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Телефон</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={personalData.phone}
|
||||
onChange={(e) => handlePersonalDataChange('phone', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={personalData.email}
|
||||
onChange={(e) => handlePersonalDataChange('email', e.target.value)}
|
||||
placeholder="@"
|
||||
className="form-input email-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notification-section">
|
||||
<div className="notification-toggle">
|
||||
<label className="toggle-label">
|
||||
<div className="toggle-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={personalData.emailNotifications}
|
||||
onChange={(e) => handlePersonalDataChange('emailNotifications', e.target.checked)}
|
||||
className="hidden-checkbox"
|
||||
/>
|
||||
<div className={`toggle-switch ${personalData.emailNotifications ? 'active' : 'inactive'}`}>
|
||||
<div className={`toggle-knob ${personalData.emailNotifications ? 'active' : 'inactive'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="toggle-text">
|
||||
Получать уведомления по Email
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="notification-toggle">
|
||||
<label className="toggle-label">
|
||||
<div className="toggle-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={personalData.smsNotifications}
|
||||
onChange={(e) => handlePersonalDataChange('smsNotifications', e.target.checked)}
|
||||
className="hidden-checkbox"
|
||||
/>
|
||||
<div className={`toggle-switch ${personalData.smsNotifications ? 'active' : 'inactive'}`}>
|
||||
<div className={`toggle-knob ${personalData.smsNotifications ? 'active' : 'inactive'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="toggle-text">
|
||||
Получать SMS уведомления
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Юридические лица */}
|
||||
<div className="settings-card">
|
||||
<h2 className="card-title">
|
||||
Юридические лица
|
||||
</h2>
|
||||
|
||||
{legalEntities.length > 0 ? (
|
||||
<div className="legal-entities">
|
||||
{legalEntities.map((entity: LegalEntity) => (
|
||||
<div key={entity.id} className="entity-card">
|
||||
<div className="entity-header">
|
||||
<div className="entity-info">
|
||||
<div>
|
||||
<h3>{entity.shortName}</h3>
|
||||
<p>ИНН {entity.inn}</p>
|
||||
|
||||
<div className="entity-actions">
|
||||
<button className="entity-link">
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none">
|
||||
<path d="M2 3H14M2 7H14M2 11H14" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
Реквизиты компании
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-controls">
|
||||
<button
|
||||
onClick={() => handleEditEntity(entity)}
|
||||
className="control-button"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 10L10 2L12 4L4 12H2V10Z" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
Редактировать
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteEntity(entity.id)}
|
||||
className="control-button"
|
||||
>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none">
|
||||
<path d="M1 4H13M5 1H9M5 7V13M9 7V13" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>У вас пока нет добавленных юридических лиц</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
onClick={handleSavePersonalData}
|
||||
className="btn-primary"
|
||||
>
|
||||
Сохранить изменения
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Добавить юридическое лицо
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно */}
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h2 className="modal-title">
|
||||
{editingEntity ? 'Редактирование юридического лица' : 'Добавление юридического лица'}
|
||||
</h2>
|
||||
|
||||
<div className="modal-form">
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">ИНН *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.inn}
|
||||
onChange={(e) => handleNewEntityChange('inn', e.target.value)}
|
||||
placeholder="ИНН"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Краткое наименование *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.shortName}
|
||||
onChange={(e) => handleNewEntityChange('shortName', e.target.value)}
|
||||
placeholder="Название компании"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Форма</label>
|
||||
<select
|
||||
value={newEntity.form}
|
||||
onChange={(e) => handleNewEntityChange('form', e.target.value)}
|
||||
className="select-input"
|
||||
>
|
||||
<option value="">Выбрать</option>
|
||||
<option value="ООО">ООО</option>
|
||||
<option value="ИП">ИП</option>
|
||||
<option value="АО">АО</option>
|
||||
<option value="ЗАО">ЗАО</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">ОГРН</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.ogrn}
|
||||
onChange={(e) => handleNewEntityChange('ogrn', e.target.value)}
|
||||
placeholder="ОГРН"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Юридический адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.legalAddress}
|
||||
onChange={(e) => handleNewEntityChange('legalAddress', e.target.value)}
|
||||
placeholder="Юридический адрес"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Полное наименование</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.fullName}
|
||||
onChange={(e) => handleNewEntityChange('fullName', e.target.value)}
|
||||
placeholder="Полное наименование"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Фактический адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.actualAddress}
|
||||
onChange={(e) => handleNewEntityChange('actualAddress', e.target.value)}
|
||||
placeholder="Фактический адрес"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Система налогообложения</label>
|
||||
<select
|
||||
value={newEntity.taxSystem}
|
||||
onChange={(e) => handleNewEntityChange('taxSystem', e.target.value)}
|
||||
className="select-input"
|
||||
>
|
||||
<option value="">Выбрать</option>
|
||||
<option value="УСН">УСН</option>
|
||||
<option value="ОСНО">ОСНО</option>
|
||||
<option value="ЕНВД">ЕНВД</option>
|
||||
<option value="ПСН">ПСН</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">НДС %</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newEntity.vatPercent}
|
||||
onChange={(e) => handleNewEntityChange('vatPercent', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Бухгалтер</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.accountant}
|
||||
onChange={(e) => handleNewEntityChange('accountant', e.target.value)}
|
||||
placeholder="ФИО"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Ответственный</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.responsibleName}
|
||||
onChange={(e) => handleNewEntityChange('responsibleName', e.target.value)}
|
||||
placeholder="ФИО"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Должность ответственного</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.responsiblePosition}
|
||||
onChange={(e) => handleNewEntityChange('responsiblePosition', e.target.value)}
|
||||
placeholder="Должность"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-field">
|
||||
<label className="form-label">Телефон ответственного</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={newEntity.responsiblePhone}
|
||||
onChange={(e) => handleNewEntityChange('responsiblePhone', e.target.value)}
|
||||
placeholder="+7"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-label">Подписант</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEntity.signatory}
|
||||
onChange={(e) => handleNewEntityChange('signatory', e.target.value)}
|
||||
placeholder="ФИО"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-buttons">
|
||||
<button
|
||||
onClick={editingEntity ? handleUpdateEntity : handleAddEntity}
|
||||
className="btn-primary"
|
||||
>
|
||||
{editingEntity ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.page-wrapper {
|
||||
background-color: #F5F8FB;
|
||||
min-height: 100vh;
|
||||
font-family: 'Onest', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
background: white;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1660px;
|
||||
margin: 0 auto;
|
||||
padding: 0 130px;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: #8E9AAC;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 40px 0 60px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 30px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.personal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #000814;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 4px;
|
||||
padding: 16px 24px;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
font-family: 'Onest', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #EC1C24;
|
||||
}
|
||||
|
||||
.email-input::placeholder {
|
||||
color: #747474;
|
||||
}
|
||||
|
||||
.notification-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.notification-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidden-checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background-color: #000814;
|
||||
}
|
||||
|
||||
.toggle-switch.inactive {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-knob.active {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.toggle-knob.inactive {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 16px;
|
||||
color: #424F60;
|
||||
}
|
||||
|
||||
.legal-entities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entity-card {
|
||||
background: #F5F8FB;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.entity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entity-info h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.entity-info p {
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
margin: 4px 0 8px 0;
|
||||
}
|
||||
|
||||
.entity-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.entity-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.entity-link:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.entity-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
color: #424F60;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
color: #EC1C24;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
color: #8E9AAC;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #EC1C24;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d91920;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #000814;
|
||||
border: 1px solid #EC1C24;
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #EC1C24;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #000814;
|
||||
margin: 0 0 30px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 4px;
|
||||
padding: 16px 24px;
|
||||
font-size: 14px;
|
||||
color: #747474;
|
||||
font-family: 'Onest', sans-serif;
|
||||
background: white;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23747474' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
border-color: #EC1C24;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 18px;
|
||||
color: #000814;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.entity-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.entity-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettings;
|
678
src/pages/search-result.tsx
Normal file
678
src/pages/search-result.tsx
Normal file
@ -0,0 +1,678 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useQuery, useLazyQuery } from "@apollo/client";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Filters, { FilterConfig } from "@/components/Filters";
|
||||
import BestPriceCard from "@/components/BestPriceCard";
|
||||
import CoreProductCard from "@/components/CoreProductCard";
|
||||
import AnalogueBlock from "@/components/AnalogueBlock";
|
||||
import InfoSearch from "@/components/InfoSearch";
|
||||
import FiltersPanelMobile from "@/components/FiltersPanelMobile";
|
||||
import CatalogSortDropdown from "@/components/CatalogSortDropdown";
|
||||
import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
|
||||
import { SEARCH_PRODUCT_OFFERS, GET_ANALOG_OFFERS } from "@/lib/graphql";
|
||||
import { useArticleImage } from "@/hooks/useArticleImage";
|
||||
|
||||
const ANALOGS_CHUNK_SIZE = 5;
|
||||
|
||||
const sortOptions = [
|
||||
"По цене",
|
||||
"По рейтингу",
|
||||
"По количеству"
|
||||
];
|
||||
|
||||
// Функция для создания динамических фильтров
|
||||
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
|
||||
const filters: FilterConfig[] = [];
|
||||
|
||||
if (result) {
|
||||
// Фильтр по производителю
|
||||
const brands = new Set<string>();
|
||||
brands.add(result.brand);
|
||||
|
||||
result.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.brand) brands.add(offer.brand);
|
||||
});
|
||||
|
||||
result.analogs?.forEach((analog: any) => {
|
||||
if (analog.brand) brands.add(analog.brand);
|
||||
});
|
||||
|
||||
if (brands.size > 1) {
|
||||
filters.push({
|
||||
type: "dropdown",
|
||||
title: "Производитель",
|
||||
options: Array.from(brands).sort(),
|
||||
multi: true,
|
||||
showAll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтр по цене
|
||||
const prices: number[] = [];
|
||||
result.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.price > 0) prices.push(offer.price);
|
||||
});
|
||||
result.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.price > 0) prices.push(offer.price);
|
||||
});
|
||||
|
||||
// Добавляем цены аналогов
|
||||
Object.values(loadedAnalogs).forEach((analog: any) => {
|
||||
analog.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.price > 0) prices.push(offer.price);
|
||||
});
|
||||
analog.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.price > 0) prices.push(offer.price);
|
||||
});
|
||||
});
|
||||
|
||||
if (prices.length > 0) {
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
|
||||
if (maxPrice > minPrice) {
|
||||
filters.push({
|
||||
type: "range",
|
||||
title: "Цена (₽)",
|
||||
min: Math.floor(minPrice),
|
||||
max: Math.ceil(maxPrice),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по сроку доставки
|
||||
const deliveryDays: number[] = [];
|
||||
result.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays);
|
||||
});
|
||||
result.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime);
|
||||
});
|
||||
|
||||
// Добавляем сроки доставки аналогов
|
||||
Object.values(loadedAnalogs).forEach((analog: any) => {
|
||||
analog.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays);
|
||||
});
|
||||
analog.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime);
|
||||
});
|
||||
});
|
||||
|
||||
if (deliveryDays.length > 0) {
|
||||
const minDays = Math.min(...deliveryDays);
|
||||
const maxDays = Math.max(...deliveryDays);
|
||||
|
||||
if (maxDays > minDays) {
|
||||
filters.push({
|
||||
type: "range",
|
||||
title: "Срок доставки (дни)",
|
||||
min: minDays,
|
||||
max: maxDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по количеству наличия
|
||||
const quantities: number[] = [];
|
||||
result.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
|
||||
});
|
||||
result.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
|
||||
});
|
||||
|
||||
// Добавляем количества аналогов
|
||||
Object.values(loadedAnalogs).forEach((analog: any) => {
|
||||
analog.internalOffers?.forEach((offer: any) => {
|
||||
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
|
||||
});
|
||||
analog.externalOffers?.forEach((offer: any) => {
|
||||
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
|
||||
});
|
||||
});
|
||||
|
||||
if (quantities.length > 0) {
|
||||
const minQuantity = Math.min(...quantities);
|
||||
const maxQuantity = Math.max(...quantities);
|
||||
|
||||
if (maxQuantity > minQuantity) {
|
||||
filters.push({
|
||||
type: "range",
|
||||
title: "Количество (шт.)",
|
||||
min: minQuantity,
|
||||
max: maxQuantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
// Функция для получения лучших предложений по заданным критериям
|
||||
const getBestOffers = (offers: any[]) => {
|
||||
const validOffers = offers.filter(offer => offer.price > 0 && typeof offer.deliveryDuration !== 'undefined');
|
||||
if (validOffers.length === 0) return [];
|
||||
|
||||
const result: { offer: any; type: string }[] = [];
|
||||
const usedOfferIds = new Set<string>();
|
||||
|
||||
// 1. Самая низкая цена (среди всех предложений)
|
||||
const lowestPriceOffer = [...validOffers].sort((a, b) => a.price - b.price)[0];
|
||||
if (lowestPriceOffer) {
|
||||
result.push({ offer: lowestPriceOffer, type: 'Самая низкая цена' });
|
||||
usedOfferIds.add(`${lowestPriceOffer.articleNumber}-${lowestPriceOffer.price}-${lowestPriceOffer.deliveryDuration}`);
|
||||
}
|
||||
|
||||
// 2. Самый дешевый аналог (только среди аналогов)
|
||||
const analogOffers = validOffers.filter(offer => offer.isAnalog);
|
||||
if (analogOffers.length > 0) {
|
||||
const cheapestAnalogOffer = [...analogOffers].sort((a, b) => a.price - b.price)[0];
|
||||
const analogId = `${cheapestAnalogOffer.articleNumber}-${cheapestAnalogOffer.price}-${cheapestAnalogOffer.deliveryDuration}`;
|
||||
|
||||
if (!usedOfferIds.has(analogId)) {
|
||||
result.push({ offer: cheapestAnalogOffer, type: 'Самый дешевый аналог' });
|
||||
usedOfferIds.add(analogId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Самая быстрая доставка (среди всех предложений)
|
||||
const fastestDeliveryOffer = [...validOffers].sort((a, b) => a.deliveryDuration - b.deliveryDuration)[0];
|
||||
if (fastestDeliveryOffer) {
|
||||
const fastestId = `${fastestDeliveryOffer.articleNumber}-${fastestDeliveryOffer.price}-${fastestDeliveryOffer.deliveryDuration}`;
|
||||
|
||||
if (!usedOfferIds.has(fastestId)) {
|
||||
result.push({ offer: fastestDeliveryOffer, type: 'Самая быстрая доставка' });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const transformOffersForCard = (offers: any[]) => {
|
||||
return offers.map(offer => {
|
||||
const isExternal = offer.type === 'external';
|
||||
return {
|
||||
id: offer.id,
|
||||
productId: offer.productId,
|
||||
offerKey: offer.offerKey,
|
||||
pcs: `${offer.quantity} шт.`,
|
||||
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`,
|
||||
recommended: !isExternal && offer.available,
|
||||
price: `${offer.price.toLocaleString('ru-RU')} ₽`,
|
||||
count: "1",
|
||||
isExternal,
|
||||
currency: offer.currency || "RUB",
|
||||
warehouse: offer.warehouse,
|
||||
supplier: offer.supplier,
|
||||
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function SearchResult() {
|
||||
const router = useRouter();
|
||||
const { article, brand, q, artId } = router.query;
|
||||
|
||||
const [sortActive, setSortActive] = useState(0);
|
||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||
const [showSortMobile, setShowSortMobile] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [brandQuery, setBrandQuery] = useState<string>("");
|
||||
const [loadedAnalogs, setLoadedAnalogs] = useState<{ [key: string]: any }>({});
|
||||
const [visibleAnalogsCount, setVisibleAnalogsCount] = useState(ANALOGS_CHUNK_SIZE);
|
||||
|
||||
// Состояния для фильтров
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [priceRange, setPriceRange] = useState<[number, number] | null>(null);
|
||||
const [deliveryRange, setDeliveryRange] = useState<[number, number] | null>(null);
|
||||
const [quantityRange, setQuantityRange] = useState<[number, number] | null>(null);
|
||||
const [filterSearchTerm, setFilterSearchTerm] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (article && typeof article === 'string') {
|
||||
setSearchQuery(article.trim().toUpperCase());
|
||||
}
|
||||
if (brand && typeof brand === 'string') {
|
||||
setBrandQuery(brand.trim());
|
||||
}
|
||||
setLoadedAnalogs({});
|
||||
setVisibleAnalogsCount(ANALOGS_CHUNK_SIZE);
|
||||
}, [article, brand]);
|
||||
|
||||
const { data, loading, error } = useQuery(SEARCH_PRODUCT_OFFERS, {
|
||||
variables: {
|
||||
articleNumber: searchQuery,
|
||||
brand: brandQuery || '' // Используем пустую строку если бренд не указан
|
||||
},
|
||||
skip: !searchQuery,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const { imageUrl: mainImageUrl } = useArticleImage(artId as string, { enabled: !!artId });
|
||||
|
||||
const [
|
||||
getAnalogOffers,
|
||||
{ loading: analogsLoading, data: analogsData }
|
||||
] = useLazyQuery(GET_ANALOG_OFFERS, {
|
||||
onCompleted: (data) => {
|
||||
if (data && data.getAnalogOffers) {
|
||||
const newAnalogs = data.getAnalogOffers.reduce((acc: any, analog: any) => {
|
||||
const key = `${analog.brand}-${analog.articleNumber}`;
|
||||
// Сразу трансформируем, но пока не используем
|
||||
// const offers = transformOffersForCard(analog.internalOffers, analog.externalOffers);
|
||||
acc[key] = { ...analog }; // offers убрали, т.к. allOffers - единый источник
|
||||
return acc;
|
||||
}, {});
|
||||
setLoadedAnalogs(prev => ({ ...prev, ...newAnalogs }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Эффект для автоматической загрузки предложений видимых аналогов
|
||||
useEffect(() => {
|
||||
if (data?.searchProductOffers?.analogs) {
|
||||
const analogsToLoad = data.searchProductOffers.analogs
|
||||
.slice(0, visibleAnalogsCount)
|
||||
.filter((a: any) => !loadedAnalogs[`${a.brand}-${a.articleNumber}`])
|
||||
.map((a: any) => ({ brand: a.brand, articleNumber: a.articleNumber }));
|
||||
|
||||
if (analogsToLoad.length > 0) {
|
||||
getAnalogOffers({ variables: { analogs: analogsToLoad } });
|
||||
}
|
||||
}
|
||||
}, [visibleAnalogsCount, data, getAnalogOffers, loadedAnalogs]);
|
||||
|
||||
const result = data?.searchProductOffers;
|
||||
|
||||
const allOffers = useMemo(() => {
|
||||
if (!result) return [];
|
||||
|
||||
const offers: any[] = [];
|
||||
|
||||
// Основной товар
|
||||
result.internalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryDays, type: 'internal', brand: result.brand, articleNumber: result.articleNumber, name: result.name }));
|
||||
result.externalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryTime, type: 'external', articleNumber: o.code, name: o.name }));
|
||||
|
||||
// Аналоги
|
||||
Object.values(loadedAnalogs).forEach((analog: any) => {
|
||||
analog.internalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryDays, type: 'internal', brand: analog.brand, articleNumber: analog.articleNumber, name: analog.name, isAnalog: true }));
|
||||
analog.externalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryTime, type: 'external', brand: o.brand || analog.brand, articleNumber: o.code || analog.articleNumber, name: o.name, isAnalog: true }));
|
||||
});
|
||||
|
||||
return offers;
|
||||
}, [result, loadedAnalogs]);
|
||||
|
||||
|
||||
const filteredOffers = useMemo(() => {
|
||||
return allOffers.filter(offer => {
|
||||
// Фильтр по бренду
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(offer.brand)) {
|
||||
return false;
|
||||
}
|
||||
// Фильтр по цене
|
||||
if (priceRange && (offer.price < priceRange[0] || offer.price > priceRange[1])) {
|
||||
return false;
|
||||
}
|
||||
// Фильтр по сроку доставки
|
||||
if (deliveryRange) {
|
||||
const deliveryDays = offer.deliveryDuration;
|
||||
if (deliveryDays < deliveryRange[0] || deliveryDays > deliveryRange[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Фильтр по количеству наличия
|
||||
if (quantityRange) {
|
||||
const quantity = offer.quantity;
|
||||
if (quantity < quantityRange[0] || quantity > quantityRange[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Фильтр по поисковой строке
|
||||
if (filterSearchTerm) {
|
||||
const searchTerm = filterSearchTerm.toLowerCase();
|
||||
const brandMatch = offer.brand.toLowerCase().includes(searchTerm);
|
||||
const articleMatch = offer.articleNumber.toLowerCase().includes(searchTerm);
|
||||
const nameMatch = offer.name.toLowerCase().includes(searchTerm);
|
||||
if (!brandMatch && !articleMatch && !nameMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allOffers, selectedBrands, priceRange, deliveryRange, quantityRange, filterSearchTerm]);
|
||||
|
||||
const handleFilterChange = (type: string, value: any) => {
|
||||
if (type === 'Производитель') {
|
||||
setSelectedBrands(value);
|
||||
} else if (type === 'Цена (₽)') {
|
||||
setPriceRange(value);
|
||||
} else if (type === 'Срок доставки (дни)') {
|
||||
setDeliveryRange(value);
|
||||
} else if (type === 'Количество (шт.)') {
|
||||
setQuantityRange(value);
|
||||
} else if (type === 'search') {
|
||||
setFilterSearchTerm(value);
|
||||
}
|
||||
};
|
||||
|
||||
const initialOffersExist = allOffers.length > 0;
|
||||
|
||||
const filtersAreActive = selectedBrands.length > 0 || priceRange !== null || deliveryRange !== null || quantityRange !== null || filterSearchTerm !== '';
|
||||
|
||||
const hasOffers = result && (result.internalOffers.length > 0 || result.externalOffers.length > 0);
|
||||
const hasAnalogs = result && result.analogs.length > 0;
|
||||
const searchResultFilters = createFilters(result, loadedAnalogs);
|
||||
const bestOffersData = getBestOffers(filteredOffers);
|
||||
|
||||
|
||||
|
||||
// Если это поиск по параметру q (из UnitDetailsSection), используем q как article
|
||||
useEffect(() => {
|
||||
if (q && typeof q === 'string' && !article) {
|
||||
setSearchQuery(q.trim().toUpperCase());
|
||||
// Определяем бренд из каталога или используем дефолтный
|
||||
const catalogFromUrl = router.query.catalog as string;
|
||||
const vehicleFromUrl = router.query.vehicle as string;
|
||||
|
||||
if (catalogFromUrl) {
|
||||
// Маппинг каталогов к брендам
|
||||
const catalogToBrandMap: { [key: string]: string } = {
|
||||
'AU1587': 'AUDI',
|
||||
'VW1587': 'VOLKSWAGEN',
|
||||
'BMW1587': 'BMW',
|
||||
'MB1587': 'MERCEDES-BENZ',
|
||||
// Добавьте другие маппинги по необходимости
|
||||
};
|
||||
|
||||
setBrandQuery(catalogToBrandMap[catalogFromUrl] || '');
|
||||
} else {
|
||||
setBrandQuery('');
|
||||
}
|
||||
}
|
||||
}, [q, article, router.query]);
|
||||
|
||||
// Удаляем старую заглушку - теперь обрабатываем все типы поиска
|
||||
|
||||
const minPrice = useMemo(() => {
|
||||
if (result && result.minPrice) return result.minPrice;
|
||||
if (allOffers.length > 0) {
|
||||
const prices = allOffers.filter(o => o.price > 0).map(o => o.price);
|
||||
if (prices.length > 0) {
|
||||
return `${Math.min(...prices).toLocaleString('ru-RU')} ₽`;
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}, [result, allOffers]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск предложений {searchQuery} - Protek</title>
|
||||
</Head>
|
||||
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">Поиск предложений...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{result ? `${result.brand} ${result.articleNumber} - ${result.name}` : `Результаты поиска`} - Protek</title>
|
||||
<meta name="description" content={`Лучшие предложения и аналоги для ${result?.name}`} />
|
||||
<meta content="Search result" property="og:title" />
|
||||
<meta content="Search result" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoSearch
|
||||
brand={result ? result.brand : brandQuery}
|
||||
articleNumber={result ? result.articleNumber : searchQuery}
|
||||
name={result ? result.name : "деталь"}
|
||||
offersCount={result ? result.totalOffers : 0}
|
||||
minPrice={minPrice}
|
||||
/>
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<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)}>
|
||||
<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">
|
||||
<path d="M21 4H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10 4H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 20H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 20H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M14 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8 10V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16 18V22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>Фильтры</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Мобильная панель фильтров */}
|
||||
<FiltersPanelMobile
|
||||
filters={searchResultFilters}
|
||||
open={showFiltersMobile}
|
||||
onClose={() => setShowFiltersMobile(false)}
|
||||
searchQuery={filterSearchTerm}
|
||||
onSearchChange={(value) => handleFilterChange('search', value)}
|
||||
/>
|
||||
{/* Лучшие предложения */}
|
||||
{bestOffersData.length > 0 && (
|
||||
<section className="section-6">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex flex-block-36">
|
||||
{bestOffersData.map(({ offer, type }, index) => (
|
||||
<BestPriceCard
|
||||
key={`best-${type}-${index}`}
|
||||
bestOfferType={type}
|
||||
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
|
||||
description={offer.name}
|
||||
price={`${offer.price.toLocaleString()} ₽`}
|
||||
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
|
||||
stock={`${offer.quantity} шт.`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Если нет предложений после фильтрации, но они были изначально */}
|
||||
{initialOffersExist && filteredOffers.length === 0 && !loading && (
|
||||
<section>
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Нет предложений, соответствующих вашим фильтрам
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Попробуйте изменить или сбросить фильтры.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Если изначально не было предложений */}
|
||||
{!initialOffersExist && !loading && (
|
||||
<section>
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
К сожалению, предложений по данному артикулу не найдено
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Попробуйте изменить параметры поиска или обратитесь к нашим менеджерам
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">Телефон: +7 (495) 123-45-67</p>
|
||||
<p className="text-sm text-gray-500">Email: info@protek.ru</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-13-copy">
|
||||
{/* Фильтры для десктопа */}
|
||||
<div style={{ width: '300px', marginRight: '20px' }}>
|
||||
<Filters
|
||||
filters={searchResultFilters}
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValues={{
|
||||
'Производитель': selectedBrands,
|
||||
'Цена (₽)': priceRange,
|
||||
'Срок доставки (дни)': deliveryRange,
|
||||
'Количество (шт.)': quantityRange
|
||||
}}
|
||||
searchQuery={filterSearchTerm}
|
||||
onSearchChange={(value) => handleFilterChange('search', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Основной товар */}
|
||||
<div className="w-layout-vflex flex-block-14-copy">
|
||||
{hasOffers && result && (() => {
|
||||
const mainProductOffers = transformOffersForCard(
|
||||
filteredOffers.filter(o => !o.isAnalog)
|
||||
);
|
||||
|
||||
// Не показываем основной товар, если у него нет предложений
|
||||
if (mainProductOffers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CoreProductCard
|
||||
brand={result.brand}
|
||||
article={result.articleNumber}
|
||||
name={result.name}
|
||||
image={mainImageUrl}
|
||||
offers={mainProductOffers}
|
||||
showMoreText={mainProductOffers.length < filteredOffers.filter(o => !o.isAnalog).length ? "Показать еще" : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Аналоги */}
|
||||
{hasAnalogs && result && (() => {
|
||||
// Фильтруем аналоги с предложениями
|
||||
const analogsWithOffers = result.analogs.slice(0, visibleAnalogsCount).filter((analog: any) => {
|
||||
const analogKey = `${analog.brand}-${analog.articleNumber}`;
|
||||
const loadedAnalogData = loadedAnalogs[analogKey];
|
||||
|
||||
if (!loadedAnalogData) {
|
||||
return true; // Показываем загружающиеся аналоги
|
||||
}
|
||||
|
||||
const analogOffers = transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
);
|
||||
|
||||
// Показываем аналог только если у него есть предложения
|
||||
return analogOffers.length > 0;
|
||||
});
|
||||
|
||||
// Если нет аналогов с предложениями, не показываем секцию
|
||||
if (analogsWithOffers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h2 className="heading-11">Аналоги</h2>
|
||||
{analogsWithOffers.map((analog: any, index: number) => {
|
||||
const analogKey = `${analog.brand}-${analog.articleNumber}`;
|
||||
const loadedAnalogData = loadedAnalogs[analogKey];
|
||||
|
||||
const analogOffers = loadedAnalogData
|
||||
? transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<CoreProductCard
|
||||
key={analogKey}
|
||||
brand={analog.brand}
|
||||
article={analog.articleNumber}
|
||||
name={analog.name}
|
||||
offers={analogOffers}
|
||||
isAnalog
|
||||
isLoadingOffers={!loadedAnalogData}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{(() => {
|
||||
// Проверяем, есть ли еще аналоги с предложениями для загрузки
|
||||
const remainingAnalogs = result.analogs.slice(visibleAnalogsCount);
|
||||
const hasMoreAnalogsWithOffers = remainingAnalogs.some((analog: any) => {
|
||||
const analogKey = `${analog.brand}-${analog.articleNumber}`;
|
||||
const loadedAnalogData = loadedAnalogs[analogKey];
|
||||
|
||||
if (!loadedAnalogData) {
|
||||
return true; // Могут быть предложения у незагруженных аналогов
|
||||
}
|
||||
|
||||
const analogOffers = transformOffersForCard(
|
||||
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
|
||||
);
|
||||
|
||||
return analogOffers.length > 0;
|
||||
});
|
||||
|
||||
return hasMoreAnalogsWithOffers && (
|
||||
<div className="w-layout-hflex pagination">
|
||||
<button
|
||||
onClick={() => setVisibleAnalogsCount(prev => prev + ANALOGS_CHUNK_SIZE)}
|
||||
disabled={analogsLoading}
|
||||
className="button_strock w-button"
|
||||
>
|
||||
{analogsLoading ? "Загружаем..." : "Показать еще"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
314
src/pages/search.tsx
Normal file
314
src/pages/search.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import MobileMenuBottomSection from '@/components/MobileMenuBottomSection';
|
||||
import { DOC_FIND_OEM, FIND_LAXIMO_VEHICLES_BY_PART_NUMBER } from '@/lib/graphql';
|
||||
import { LaximoDocFindOEMResult, LaximoVehiclesByPartResult, LaximoVehicleSearchResult } from '@/types/laximo';
|
||||
|
||||
type SearchMode = 'parts' | 'vehicles';
|
||||
|
||||
const InfoSearch = () => (
|
||||
<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>
|
||||
);
|
||||
const SearchPage = () => {
|
||||
const router = useRouter();
|
||||
const { q, mode } = router.query;
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('parts');
|
||||
|
||||
useEffect(() => {
|
||||
if (q && typeof q === 'string') {
|
||||
setSearchQuery(q.trim().toUpperCase());
|
||||
}
|
||||
if (mode && typeof mode === 'string' && (mode === 'parts' || mode === 'vehicles')) {
|
||||
setSearchMode(mode);
|
||||
}
|
||||
}, [q, mode]);
|
||||
|
||||
const { data: partsData, loading: partsLoading, error: partsError } = useQuery(DOC_FIND_OEM, {
|
||||
variables: { oemNumber: searchQuery },
|
||||
skip: !searchQuery || searchMode !== 'parts',
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const { data: vehiclesData, loading: vehiclesLoading, error: vehiclesError } = useQuery(FIND_LAXIMO_VEHICLES_BY_PART_NUMBER, {
|
||||
variables: { partNumber: searchQuery },
|
||||
skip: !searchQuery || searchMode !== 'vehicles',
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const handleSearchModeChange = (mode: SearchMode) => {
|
||||
setSearchMode(mode);
|
||||
if (searchQuery) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}&mode=${mode}`, undefined, { shallow: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim().toUpperCase())}&mode=${searchMode}`);
|
||||
};
|
||||
|
||||
const handlePartDetail = (detail: any) => {
|
||||
router.push(`/search-result?article=${encodeURIComponent(detail.formattedoem)}&brand=${encodeURIComponent(detail.manufacturer)}`);
|
||||
};
|
||||
|
||||
const handleVehicleSelect = (vehicle: LaximoVehicleSearchResult) => {
|
||||
const vehicleBrand = vehicle.brand || vehicle.name?.split(' ')[0] || 'UNKNOWN';
|
||||
const url = `/search-result?article=${encodeURIComponent(searchQuery)}&brand=${encodeURIComponent(vehicleBrand)}`;
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
const handleShowAllVehicles = (catalogCode?: string) => {
|
||||
const url = catalogCode
|
||||
? `/vehicles-by-part?partNumber=${encodeURIComponent(searchQuery)}&catalogCode=${catalogCode}`
|
||||
: `/vehicles-by-part?partNumber=${encodeURIComponent(searchQuery)}`;
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
const isLoading = (searchMode === 'parts' && partsLoading) || (searchMode === 'vehicles' && vehiclesLoading);
|
||||
const hasError = (searchMode === 'parts' && partsError) || (searchMode === 'vehicles' && vehiclesError);
|
||||
|
||||
const partsResult: LaximoDocFindOEMResult | null = partsData?.laximoDocFindOEM || null;
|
||||
const vehiclesResult: LaximoVehiclesByPartResult | null = vehiclesData?.laximoFindVehiclesByPartNumber || null;
|
||||
|
||||
const hasPartsResults = partsResult && partsResult.details && partsResult.details.length > 0;
|
||||
const hasVehiclesResults = vehiclesResult && vehiclesResult.totalVehicles > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск по артикулу {searchQuery} - Protek</title>
|
||||
<meta name="description" content={`Результаты поиска по артикулу ${searchQuery}`} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<InfoSearch />
|
||||
<div className="page-wrapper bg-[#F5F8FB] min-h-screen">
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="w-full max-w-[1580px]">
|
||||
{/* Переключатель режима поиска */}
|
||||
{/* {searchQuery && (
|
||||
<div className="bg-white rounded-2xl shadow p-6 mb-6 flex flex-col items-center">
|
||||
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1 w-full max-w-md">
|
||||
<button
|
||||
onClick={() => handleSearchModeChange('parts')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
searchMode === 'parts'
|
||||
? 'bg-white text-[#EC1C24] shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
🔧 Найти запчасти
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSearchModeChange('vehicles')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
searchMode === 'vehicles'
|
||||
? 'bg-white text-[#EC1C24] shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
🚗 Найти автомобили
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Обработка ошибок */}
|
||||
{searchQuery && hasError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl shadow p-10 mb-6">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-6 h-6 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-red-800">Ошибка поиска</h3>
|
||||
<p className="text-red-700 mt-1">Произошла ошибка при поиске. Попробуйте еще раз.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Загрузка */}
|
||||
{searchQuery && isLoading && (
|
||||
<div className="bg-white rounded-2xl shadow p-10 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-red-600 mb-6"></div>
|
||||
<p className="text-lg text-gray-600">Поиск по артикулу...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Результаты поиска */}
|
||||
{searchQuery && !isLoading && !hasError && (
|
||||
<div className="space-y-6">
|
||||
{/* Результаты поиска запчастей */}
|
||||
{searchMode === 'parts' && (
|
||||
<>
|
||||
{!hasPartsResults && (
|
||||
<div className="bg-[#eaf0fa] border border-[#b3c6e6] rounded-2xl shadow p-10 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4" style={{ color: '#0d336c' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.29-1.009-5.824-2.562M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: '#0d336c' }}>
|
||||
Детали не найдены
|
||||
</h3>
|
||||
<p className="mb-4" style={{ color: '#0d336c' }}>
|
||||
По артикулу <span className="font-mono font-semibold">{searchQuery}</span> детали не найдены.
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: '#3b5a99' }}>
|
||||
Попробуйте изменить запрос или проверьте правильность написания артикула.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPartsResults && (
|
||||
<div className="bg-white rounded-2xl shadow p-10">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Поиск деталей по артикулу: {searchQuery}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Выберите нужную деталь
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{partsResult!.details.map((detail, index) => (
|
||||
<div key={detail.detailid || index}>
|
||||
<button
|
||||
onClick={() => handlePartDetail(detail)}
|
||||
className="w-full text-left p-4 hover:bg-gray-50 transition-colors block group"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-1/5 max-md:w-1/3 font-bold text-left truncate" style={{ color: 'rgb(77, 180, 94)' }}>{detail.manufacturer}</div>
|
||||
<div className="w-1/5 max-md:text-center max-md:w-1/3 font-bold text-left truncate group-hover:text-[#EC1C24] transition-colors">{detail.formattedoem || detail.oem}</div>
|
||||
<div className="w-3/5 max-md:w-1/3 text-left truncate">{detail.name}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Результаты поиска автомобилей */}
|
||||
{searchMode === 'vehicles' && (
|
||||
<div className="bg-white rounded-2xl shadow p-10">
|
||||
<div className="border-b border-gray-200 pb-4 mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Поиск автомобилей по артикулу: {searchQuery}
|
||||
</h2>
|
||||
{hasVehiclesResults && (
|
||||
<span className="text-sm text-gray-600">
|
||||
Найдено {vehiclesResult!.totalVehicles} автомобилей в {vehiclesResult!.catalogs.length} каталогах
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasVehiclesResults ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Бренд
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Артикул
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Наименование
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Рынок
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{vehiclesResult!.catalogs.map((catalog) => (
|
||||
<tr
|
||||
key={catalog.catalogCode}
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/search-result?article=${encodeURIComponent(searchQuery)}&brand=${encodeURIComponent(catalog.brand)}`);
|
||||
}}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{catalog.brand}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{searchQuery}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">ГАЕЧНЫЙ КЛЮЧ</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-500">
|
||||
{catalog.catalogCode}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<button
|
||||
onClick={() => handleShowAllVehicles()}
|
||||
className="w-full bg-[#EC1C24] text-white py-3 px-6 rounded-lg hover:bg-[#b91c1c] transition-colors font-medium"
|
||||
>
|
||||
Показать все {vehiclesResult!.totalVehicles} автомобилей
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-2xl shadow p-10 text-center">
|
||||
<svg className="w-16 h-16 text-yellow-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-yellow-800 mb-2">Автомобили не найдены</h3>
|
||||
<p className="text-yellow-700 mb-4">Автомобили с артикулом {searchQuery} не найдены в каталогах</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileMenuBottomSection />
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
41
src/pages/set-token.tsx
Normal file
41
src/pages/set-token.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const SetToken: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Очищаем все старые данные из localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Устанавливаем данные пользователя в localStorage
|
||||
const userData = {
|
||||
id: 'cmbntpesd0000rq19p7jsszrz',
|
||||
name: 'Лев Данилов',
|
||||
phone: '+79611177205',
|
||||
email: ''
|
||||
};
|
||||
|
||||
localStorage.setItem('userData', JSON.stringify(userData));
|
||||
localStorage.setItem('authToken', `client_${userData.id}`);
|
||||
|
||||
console.log('localStorage очищен и токен установлен:', `client_${userData.id}`);
|
||||
console.log('userData установлен:', userData);
|
||||
|
||||
// Перенаправляем на страницу реквизитов
|
||||
setTimeout(() => {
|
||||
router.push('/profile-requisites');
|
||||
}, 2000);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>Установка токена...</h1>
|
||||
<p>Токен устанавливается, вы будете перенаправлены на страницу реквизитов.</p>
|
||||
<p>ID клиента: cmbntpesd0000rq19p7jsszrz</p>
|
||||
<p>Токен: client_cmbntpesd0000rq19p7jsszrz</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetToken;
|
40
src/pages/test-profile.tsx
Normal file
40
src/pages/test-profile.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const TestProfilePage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем тестового пользователя в localStorage
|
||||
const testUser = {
|
||||
id: 'cmboumhyx0002mq0i6wibydxv',
|
||||
name: 'Лев Данилов',
|
||||
phone: '+79611177205',
|
||||
email: 'lev@example.com'
|
||||
};
|
||||
|
||||
localStorage.setItem('userData', JSON.stringify(testUser));
|
||||
localStorage.setItem('authToken', `client_${testUser.id}`);
|
||||
|
||||
console.log('Тестовый пользователь установлен:', testUser);
|
||||
|
||||
// Перенаправляем на страницу профиля
|
||||
setTimeout(() => {
|
||||
router.push('/profile-set');
|
||||
}, 1000);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4">Настройка тестового пользователя</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Устанавливаем данные тестового пользователя и перенаправляем на страницу профиля...
|
||||
</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestProfilePage;
|
69
src/pages/thankyoupage.tsx
Normal file
69
src/pages/thankyoupage.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import Head from 'next/head';
|
||||
import ThankInfo from "@/components/ThankInfo";
|
||||
import Header from "@/components/Header";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import Footer from "@/components/Footer";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ThankYouPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>thankyoupage</title>
|
||||
<meta content="thankyoupage" property="og:title" />
|
||||
<meta content="thankyoupage" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js" type="text/javascript"></script>
|
||||
<script type="text/javascript" dangerouslySetInnerHTML={{__html: `WebFont.load({ google: { families: [\"Onest:regular,600,700,800,900:cyrillic-ext,latin\"] }});`}} />
|
||||
<script type="text/javascript" dangerouslySetInnerHTML={{__html: `!function(o,c){var n=c.documentElement,t=\" w-mod-\";n.className+=t+\"js\",(\"ontouchstart\"in o||o.DocumentTouch&&c instanceof DocumentTouch)&&(n.className+=t+\"touch\")}(window,document);`}} />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<ThankInfo />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<div className="w-layout-vflex flex-block-72">
|
||||
<div className="w-layout-vflex image-thx"></div>
|
||||
<div className="w-layout-vflex desc-wholesale">
|
||||
<div className="w-layout-hflex thxcontent">
|
||||
<h3 className="heading-14">Ваш заказ <span className="text-span-4">№2024ABCD123</span> успешно оплачен ✅ </h3>
|
||||
<div className="w-layout-vflex flex-block-103">
|
||||
<div className="w-layout-hflex flex-block-75">
|
||||
<div className="txtpthx">Номер вашего заказа</div>
|
||||
<div className="text-block-36">№2024ABCD123</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-75">
|
||||
<div className="txtpthx">Дата и время заказа</div>
|
||||
<div className="text-block-36">16:33 | 5 апреля 2025</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-75">
|
||||
<div className="txtpthx">Сумма заказа</div>
|
||||
<div className="text-block-36">18 000 ₽</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="thxsubtitle">📦 Что дальше?</h3>
|
||||
<div className="text-block-36">✅ Мы уже обрабатываем ваш заказ.<br />🚚 Отправка ожидается в течение 1–3 рабочих дней.<br />📬 Как только посылка будет передана в службу доставки, вы получите уведомление с трек-номером.</div>
|
||||
<div className="w-layout-hflex flex-block-104">
|
||||
<Link href="/cart" legacyBehavior><a className="submit-button-s w-button">Продолжить покупки</a></Link>
|
||||
<Link href="/cart" legacyBehavior><a className="button_strock-s w-button">К списку заказазов</a></Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
497
src/pages/vehicle-search-results.tsx
Normal file
497
src/pages/vehicle-search-results.tsx
Normal file
@ -0,0 +1,497 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useLazyQuery } from '@apollo/client';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { FIND_LAXIMO_VEHICLE, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL } from '@/lib/graphql';
|
||||
import { LaximoVehicleSearchResult } from '@/types/laximo';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface VehicleSearchResultsPageProps {}
|
||||
|
||||
const VehicleSearchResultsPage: React.FC<VehicleSearchResultsPageProps> = () => {
|
||||
const router = useRouter();
|
||||
const { query: routerQuery } = router;
|
||||
const [vehicles, setVehicles] = useState<LaximoVehicleSearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchType, setSearchType] = useState<'vin' | 'plate' | ''>('');
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
// Query для поиска по VIN
|
||||
const [findVehicleByVin] = useLazyQuery(FIND_LAXIMO_VEHICLE, {
|
||||
onCompleted: (data) => {
|
||||
const results = data.laximoFindVehicle || [];
|
||||
setVehicles(results);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Ошибка поиска по VIN:', error);
|
||||
setVehicles([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Query для поиска по госномеру
|
||||
const [findVehicleByPlate] = useLazyQuery(FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL, {
|
||||
onCompleted: (data) => {
|
||||
const results = data.laximoFindVehicleByPlateGlobal || [];
|
||||
setVehicles(results);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Ошибка поиска по госномеру:', error);
|
||||
setVehicles([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Проверяем тип поиска
|
||||
const isVinNumber = (query: string): boolean => {
|
||||
const cleanQuery = query.trim().toUpperCase();
|
||||
return /^[A-HJ-NPR-Z0-9]{17}$/.test(cleanQuery);
|
||||
};
|
||||
|
||||
const isPlateNumber = (query: string): boolean => {
|
||||
const cleanQuery = query.trim().toUpperCase().replace(/\s+/g, '');
|
||||
const platePatterns = [
|
||||
/^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\d{2,3}$/,
|
||||
/^[АВЕКМНОРСТУХ]{2}\d{3}[АВЕКМНОРСТУХ]\d{2,3}$/,
|
||||
/^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\d{2,3}$/,
|
||||
];
|
||||
return platePatterns.some(pattern => pattern.test(cleanQuery));
|
||||
};
|
||||
|
||||
// Выполняем поиск при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (routerQuery.q && typeof routerQuery.q === 'string') {
|
||||
const query = routerQuery.q.trim();
|
||||
setSearchQuery(query);
|
||||
setIsLoading(true);
|
||||
|
||||
if (isVinNumber(query)) {
|
||||
setSearchType('vin');
|
||||
findVehicleByVin({
|
||||
variables: {
|
||||
catalogCode: '', // Глобальный поиск
|
||||
vin: query.toUpperCase()
|
||||
}
|
||||
});
|
||||
} else if (isPlateNumber(query)) {
|
||||
setSearchType('plate');
|
||||
findVehicleByPlate({
|
||||
variables: {
|
||||
plateNumber: query.toUpperCase().replace(/\s+/g, '')
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [routerQuery.q, findVehicleByVin, findVehicleByPlate]);
|
||||
|
||||
const handleVehicleSelect = useCallback((vehicle: LaximoVehicleSearchResult, skipToCategories = false) => {
|
||||
console.log('🚗 handleVehicleSelect вызвана для автомобиля:', vehicle, 'skipToCategories:', skipToCategories);
|
||||
|
||||
// Переходим к выбору групп запчастей для найденного автомобиля
|
||||
const catalogCode = vehicle.catalog || vehicle.brand?.toLowerCase() || '';
|
||||
const vehicleId = vehicle.vehicleid || '';
|
||||
const ssd = vehicle.ssd || '';
|
||||
|
||||
console.log('🔧 Выбранные параметры:', {
|
||||
catalogCode,
|
||||
vehicleId,
|
||||
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: ssd.length
|
||||
});
|
||||
|
||||
// Если есть SSD, сохраняем его в localStorage для безопасной передачи
|
||||
if (ssd && ssd.trim() !== '') {
|
||||
const vehicleKey = `vehicle_ssd_${catalogCode}_${vehicleId}`;
|
||||
console.log('💾 Сохраняем SSD в localStorage, ключ:', vehicleKey);
|
||||
|
||||
// Очищаем все предыдущие SSD для других автомобилей
|
||||
const keysToRemove = Object.keys(localStorage).filter(key => key.startsWith('vehicle_ssd_'));
|
||||
keysToRemove.forEach(key => {
|
||||
if (key !== vehicleKey) {
|
||||
console.log('🗑️ Удаляем старый SSD ключ:', key);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem(vehicleKey, ssd);
|
||||
|
||||
// Выбираем URL в зависимости от того, нужно ли пропустить промежуточную страницу
|
||||
const url = skipToCategories
|
||||
? `/vehicle-search/${catalogCode}/${vehicleId}?use_storage=1&ssd_length=${ssd.length}&searchType=categories`
|
||||
: `/vehicle-search/${catalogCode}/${vehicleId}?use_storage=1&ssd_length=${ssd.length}`;
|
||||
|
||||
console.log('🔗 Переходим на URL с localStorage:', url);
|
||||
// Используем replace вместо push для моментального перехода
|
||||
router.replace(url);
|
||||
} else {
|
||||
// Выбираем URL в зависимости от того, нужно ли пропустить промежуточную страницу
|
||||
const url = skipToCategories
|
||||
? `/vehicle-search/${catalogCode}/${vehicleId}?searchType=categories`
|
||||
: `/vehicle-search/${catalogCode}/${vehicleId}`;
|
||||
|
||||
console.log('🔗 Переходим на URL без SSD:', url);
|
||||
// Используем replace вместо push для моментального перехода
|
||||
router.replace(url);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Предзагрузка и автоматический переход при поиске по VIN, если найден только один автомобиль
|
||||
useEffect(() => {
|
||||
if (!isLoading && searchType === 'vin' && vehicles.length === 1 && !isRedirecting) {
|
||||
console.log('🚗 Найден один автомобиль по VIN, подготавливаем мгновенный переход');
|
||||
|
||||
const vehicle = vehicles[0];
|
||||
const catalogCode = vehicle.catalog || vehicle.brand?.toLowerCase() || '';
|
||||
const vehicleId = vehicle.vehicleid || '';
|
||||
|
||||
// Предзагружаем целевую страницу для ускорения перехода (сразу с категориями)
|
||||
const targetUrl = `/vehicle-search/${catalogCode}/${vehicleId}?searchType=categories`;
|
||||
router.prefetch(targetUrl);
|
||||
console.log('🔄 Предзагружаем страницу с категориями:', targetUrl);
|
||||
|
||||
setIsRedirecting(true);
|
||||
|
||||
// Мгновенный переход сразу к категориям
|
||||
handleVehicleSelect(vehicle, true);
|
||||
}
|
||||
}, [isLoading, searchType, vehicles, handleVehicleSelect, isRedirecting, router]);
|
||||
|
||||
const handleCancelRedirect = () => {
|
||||
setIsRedirecting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
|
||||
<main className="bg-gray-50 min-h-screen">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<nav className="flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<Link href="/" className="text-gray-400 hover:text-gray-500">
|
||||
Главная
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<svg className="flex-shrink-0 h-5 w-5 text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M5.555 17.776l8-16 .894.448-8 16-.894-.448z" />
|
||||
</svg>
|
||||
<span className="ml-4 text-sm font-medium text-red-600">
|
||||
{searchType === 'vin' ? 'Найденные автомобили' : 'Найденные автомобили'}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results Header */}
|
||||
<div className="bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{searchType === 'vin' ? 'Поиск по VIN номеру' : 'Поиск по государственному номеру'}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Запрос: <span className="font-mono font-bold">{searchQuery}</span>
|
||||
</p>
|
||||
{!isLoading && vehicles.length > 0 && !isRedirecting && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Найдено {vehicles.length} автомобилей
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="animate-spin h-8 w-8 text-red-600" 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>
|
||||
<span className="text-lg text-gray-600">Поиск автомобилей...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-redirect notification for VIN search with single result */}
|
||||
{!isLoading && searchType === 'vin' && vehicles.length === 1 && isRedirecting && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="animate-spin h-6 w-6 text-green-600" 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>
|
||||
<h3 className="text-lg font-medium text-green-900">✅ Автомобиль найден!</h3>
|
||||
<p className="text-green-700">
|
||||
<strong>{vehicles[0]?.brand} {vehicles[0]?.name}</strong>
|
||||
{vehicles[0]?.year && ` (${vehicles[0].year} г.)`}
|
||||
{vehicles[0]?.engine && `, двигатель: ${vehicles[0].engine}`}
|
||||
</p>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
🚀 Переходим сразу к категориям запчастей...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-green-600 hover:text-green-800 border border-green-300 hover:border-green-400 px-3 py-1 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
{!isLoading && vehicles.length > 0 && !isRedirecting && (
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Бренд
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Модель
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Год
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Двигатель
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
КПП
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Рынок
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дата выпуска
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Период производства
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дополнительно
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{vehicles.map((vehicle, index) => {
|
||||
console.log('🔍 Отображаем автомобиль в таблице:', {
|
||||
index,
|
||||
vehicleid: vehicle.vehicleid,
|
||||
name: vehicle.name,
|
||||
brand: vehicle.brand,
|
||||
catalog: vehicle.catalog,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
engine: vehicle.engine,
|
||||
ssd: vehicle.ssd ? vehicle.ssd.substring(0, 30) + '...' : 'отсутствует'
|
||||
});
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={vehicle.vehicleid || index}
|
||||
onClick={() => handleVehicleSelect(vehicle)}
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{vehicle.brand}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{vehicle.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{vehicle.model}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
const year = vehicle.year || vehicle.manufactured || (vehicle.date ? vehicle.date.split('.').pop() : '') || '';
|
||||
console.log(`🗓️ Год для автомобиля ${vehicle.vehicleid}:`, { year, original_year: vehicle.year, manufactured: vehicle.manufactured, date: vehicle.date });
|
||||
return year || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
const engine = vehicle.engine || vehicle.engine_info || vehicle.engineno || '';
|
||||
console.log(`🔧 Двигатель для автомобиля ${vehicle.vehicleid}:`, { engine, original_engine: vehicle.engine, engine_info: vehicle.engine_info, engineno: vehicle.engineno });
|
||||
return engine || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
const transmission = vehicle.transmission || vehicle.bodytype || '';
|
||||
console.log(`⚙️ КПП для автомобиля ${vehicle.vehicleid}:`, { transmission, original_transmission: vehicle.transmission, bodytype: vehicle.bodytype });
|
||||
return transmission || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
const market = vehicle.market || vehicle.destinationregion || vehicle.creationregion || '';
|
||||
console.log(`🌍 Рынок для автомобиля ${vehicle.vehicleid}:`, { market, original_market: vehicle.market, destinationregion: vehicle.destinationregion, creationregion: vehicle.creationregion });
|
||||
return market || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
const releaseDate = vehicle.date || vehicle.manufactured || '';
|
||||
console.log(`📅 Дата выпуска для автомобиля ${vehicle.vehicleid}:`, { releaseDate, date: vehicle.date, manufactured: vehicle.manufactured });
|
||||
return releaseDate || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{(() => {
|
||||
let prodPeriod = '';
|
||||
if (vehicle.prodRange) {
|
||||
prodPeriod = vehicle.prodRange;
|
||||
} else if (vehicle.prodPeriod) {
|
||||
prodPeriod = vehicle.prodPeriod;
|
||||
} else if (vehicle.datefrom && vehicle.dateto) {
|
||||
prodPeriod = `${vehicle.datefrom} - ${vehicle.dateto}`;
|
||||
} else if (vehicle.modelyearfrom && vehicle.modelyearto) {
|
||||
prodPeriod = `${vehicle.modelyearfrom} - ${vehicle.modelyearto}`;
|
||||
}
|
||||
console.log(`📈 Период производства для автомобиля ${vehicle.vehicleid}:`, {
|
||||
prodPeriod,
|
||||
prodRange: vehicle.prodRange,
|
||||
original_prodPeriod: vehicle.prodPeriod,
|
||||
datefrom: vehicle.datefrom,
|
||||
dateto: vehicle.dateto,
|
||||
modelyearfrom: vehicle.modelyearfrom,
|
||||
modelyearto: vehicle.modelyearto
|
||||
});
|
||||
return prodPeriod || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">
|
||||
<div className="space-y-1">
|
||||
{vehicle.framecolor && (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">Цвет кузова:</span> {vehicle.framecolor}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.trimcolor && (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">Цвет салона:</span> {vehicle.trimcolor}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.engineno && (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">Номер двигателя:</span> {vehicle.engineno}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.engine_info && (
|
||||
<div className="text-xs max-w-xs truncate" title={vehicle.engine_info}>
|
||||
<span className="font-medium">Двигатель:</span> {vehicle.engine_info}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.options && (
|
||||
<div className="text-xs max-w-xs truncate" title={vehicle.options}>
|
||||
<span className="font-medium">Опции:</span> {vehicle.options}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.description && (
|
||||
<div className="text-xs max-w-xs truncate" title={vehicle.description}>
|
||||
<span className="font-medium">Описание:</span> {vehicle.description}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.modification && (
|
||||
<div className="text-xs max-w-xs truncate" title={vehicle.modification}>
|
||||
<span className="font-medium">Модификация:</span> {vehicle.modification}
|
||||
</div>
|
||||
)}
|
||||
{vehicle.grade && (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">Класс:</span> {vehicle.grade}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{!isLoading && vehicles.length === 0 && searchQuery && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-yellow-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">
|
||||
{searchType === 'vin' ? 'VIN не найден' : 'Госномер не найден'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{searchType === 'vin'
|
||||
? `Автомобиль с VIN номером ${searchQuery} не найден в доступных каталогах`
|
||||
: `Автомобиль с государственным номером ${searchQuery} не найден в базе данных`
|
||||
}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invalid Search Query */}
|
||||
{!isLoading && !searchQuery && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">
|
||||
Введите поисковый запрос
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Используйте поле поиска в шапке сайта для поиска автомобилей по VIN номеру или государственному номеру
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehicleSearchResultsPage;
|
150
src/pages/vehicle-search/[brand].tsx
Normal file
150
src/pages/vehicle-search/[brand].tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import VehicleSearchSection from '../../components/VehicleSearchSection';
|
||||
import { GET_LAXIMO_CATALOG_INFO } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo } from '@/types/laximo';
|
||||
|
||||
const VehicleSearchPage = () => {
|
||||
const router = useRouter();
|
||||
const { brand } = router.query;
|
||||
const [searchType, setSearchType] = useState<'vin' | 'wizard' | 'parts' | 'plate'>('vin');
|
||||
|
||||
// Получаем информацию о каталоге
|
||||
const { data: catalogData, loading: catalogLoading } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
|
||||
GET_LAXIMO_CATALOG_INFO,
|
||||
{
|
||||
variables: { catalogCode: brand },
|
||||
skip: !brand,
|
||||
errorPolicy: 'all'
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
if (catalogLoading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск автомобиля - {brand}</title>
|
||||
</Head>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">Загружаем информацию о каталоге...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalogData?.laximoCatalogInfo) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Каталог не найден - {brand}</title>
|
||||
</Head>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Каталог не найден</h1>
|
||||
<p className="text-gray-600 mb-8">Каталог "{brand}" не существует или временно недоступен</p>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Вернуться на главную
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const catalogInfo = catalogData.laximoCatalogInfo;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск автомобиля - {catalogInfo.name}</title>
|
||||
<meta name="description" content={`Поиск автомобилей ${catalogInfo.name} для подбора запчастей`} />
|
||||
</Head>
|
||||
<Header />
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Навигация */}
|
||||
<nav className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-500 hover:text-gray-700 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Главная</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>Каталог</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900 font-medium">{catalogInfo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
{catalogInfo.icon && (
|
||||
<img
|
||||
src={`/images/brands/${catalogInfo.icon}`}
|
||||
alt={catalogInfo.name}
|
||||
className="w-16 h-16 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{catalogInfo.name}</h1>
|
||||
<p className="text-lg text-gray-600 mt-2">
|
||||
Поиск автомобиля для начала подбора запчастей
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Поиск автомобиля может осуществляться по различным уникальным идентификаторам,
|
||||
таким как VIN/Frame, специальным параметрам производителя или по артикулу оригинальной детали.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<VehicleSearchSection
|
||||
catalogInfo={catalogInfo}
|
||||
searchType={searchType}
|
||||
onSearchTypeChange={setSearchType}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehicleSearchPage;
|
311
src/pages/vehicle-search/[brand]/[vehicleId].tsx
Normal file
311
src/pages/vehicle-search/[brand]/[vehicleId].tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
import Footer from '@/components/Footer';
|
||||
import Layout from '@/components/Layout';
|
||||
import VehiclePartsSearchSection from '@/components/VehiclePartsSearchSection';
|
||||
import LaximoDiagnostic from '@/components/LaximoDiagnostic';
|
||||
import { GET_LAXIMO_VEHICLE_INFO, GET_LAXIMO_CATALOG_INFO } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo } from '@/types/laximo';
|
||||
|
||||
interface LaximoVehicleInfo {
|
||||
vehicleid: string;
|
||||
name: string;
|
||||
ssd: string;
|
||||
brand: string;
|
||||
catalog: string;
|
||||
attributes: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const VehicleDetailsPage = () => {
|
||||
const router = useRouter();
|
||||
const { brand, vehicleId, oemNumber, searchType: searchTypeParam } = router.query;
|
||||
|
||||
// Устанавливаем тип поиска из URL или по умолчанию
|
||||
// Важно: согласно документации Laximo, для групп быстрого поиска используется ListQuickGroup
|
||||
// Если в URL передан searchType=categories, мы интерпретируем это как запрос на quickgroups
|
||||
let defaultSearchType: 'quickgroups' | 'categories' | 'fulltext' = 'quickgroups';
|
||||
|
||||
if (searchTypeParam === 'categories') {
|
||||
// В URL categories, но мы используем quickgroups для групп быстрого поиска
|
||||
defaultSearchType = 'quickgroups';
|
||||
console.log('🔄 URL содержит searchType=categories, интерпретируем как quickgroups (группы быстрого поиска)');
|
||||
} else if (searchTypeParam === 'quickgroups') {
|
||||
defaultSearchType = 'quickgroups';
|
||||
} else if (searchTypeParam === 'fulltext') {
|
||||
defaultSearchType = 'fulltext';
|
||||
}
|
||||
|
||||
const [searchType, setSearchType] = useState<'quickgroups' | 'categories' | 'fulltext'>(defaultSearchType);
|
||||
|
||||
// Получаем информацию о каталоге
|
||||
const { data: catalogData } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
|
||||
GET_LAXIMO_CATALOG_INFO,
|
||||
{
|
||||
variables: { catalogCode: brand },
|
||||
skip: !brand
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем информацию о выбранном автомобиле
|
||||
const ssdFromQuery = Array.isArray(router.query.ssd) ? router.query.ssd[0] : router.query.ssd;
|
||||
const useStorage = router.query.use_storage === '1';
|
||||
const ssdLengthFromUrl = router.query.ssd_length ? parseInt(router.query.ssd_length as string) : 0;
|
||||
|
||||
// Если указано use_storage, пытаемся получить SSD из localStorage
|
||||
let finalSsd = '';
|
||||
if (useStorage && typeof window !== 'undefined') {
|
||||
const vehicleKey = `vehicle_ssd_${brand}_${vehicleId}`;
|
||||
const storedSsd = localStorage.getItem(vehicleKey);
|
||||
if (storedSsd) {
|
||||
finalSsd = storedSsd;
|
||||
console.log('🔧 SSD получен из localStorage, длина:', storedSsd.length);
|
||||
// НЕ ОЧИЩАЕМ SSD сразу, оставляем на случай перезагрузки страницы
|
||||
// localStorage.removeItem(vehicleKey);
|
||||
} else {
|
||||
console.log('⚠️ SSD не найден в localStorage, ключ:', vehicleKey);
|
||||
console.log('🔍 Все ключи localStorage:', Object.keys(localStorage));
|
||||
}
|
||||
} else if (ssdFromQuery && ssdFromQuery.trim() !== '') {
|
||||
finalSsd = ssdFromQuery;
|
||||
console.log('🔧 SSD получен из URL');
|
||||
}
|
||||
|
||||
console.log('🔍 Vehicle page params:', {
|
||||
brand,
|
||||
vehicleId,
|
||||
useStorage,
|
||||
ssdLengthFromUrl,
|
||||
ssdFromQuery: ssdFromQuery ? `${ssdFromQuery.substring(0, 50)}...` : 'отсутствует',
|
||||
finalSsd: finalSsd ? `${finalSsd.substring(0, 50)}...` : 'отсутствует',
|
||||
ssdLength: finalSsd.length
|
||||
});
|
||||
|
||||
const { data: vehicleData, loading: vehicleLoading, error: vehicleError } = useQuery<{ laximoVehicleInfo: LaximoVehicleInfo }>(
|
||||
GET_LAXIMO_VEHICLE_INFO,
|
||||
{
|
||||
variables: {
|
||||
catalogCode: brand,
|
||||
vehicleId: vehicleId,
|
||||
...(finalSsd && { ssd: finalSsd }),
|
||||
localized: true
|
||||
},
|
||||
skip: !brand || !vehicleId,
|
||||
errorPolicy: 'all'
|
||||
}
|
||||
);
|
||||
|
||||
// Логируем ошибки
|
||||
if (vehicleError) {
|
||||
console.error('Vehicle GraphQL error:', vehicleError);
|
||||
}
|
||||
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Загрузка автомобиля...</title>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">Загружаем информацию об автомобиле...</p>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Если информация о каталоге недоступна, показываем ошибку
|
||||
if (!catalogData?.laximoCatalogInfo) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Каталог не найден</title>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Каталог не найден</h1>
|
||||
<p className="text-gray-600 mb-8">Информация о каталоге недоступна</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Назад к поиску
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Если информация об автомобиле недоступна, создаем заглушку
|
||||
const vehicleInfo = vehicleData?.laximoVehicleInfo || {
|
||||
vehicleid: vehicleId as string,
|
||||
name: `Автомобиль ${catalogData.laximoCatalogInfo.name}`,
|
||||
ssd: finalSsd,
|
||||
brand: catalogData.laximoCatalogInfo.brand,
|
||||
catalog: catalogData.laximoCatalogInfo.code,
|
||||
attributes: []
|
||||
};
|
||||
|
||||
// Если нет данных автомобиля и есть ошибка, показываем предупреждение
|
||||
const hasError = vehicleError && !vehicleData?.laximoVehicleInfo;
|
||||
const catalogInfo = catalogData.laximoCatalogInfo;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>{vehicleInfo.name} - Поиск запчастей</title>
|
||||
<meta name="description" content={`Поиск запчастей для ${vehicleInfo.name} в каталоге ${catalogInfo.name}`} />
|
||||
</Head>
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Навигация */}
|
||||
<nav className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-500 hover:text-gray-700 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Главная</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>Каталог</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>{catalogInfo.name}</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900 font-medium">{vehicleInfo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Информация об автомобиле */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
{catalogInfo.icon && (
|
||||
<img
|
||||
src={`/images/brands/${catalogInfo.icon}`}
|
||||
alt={catalogInfo.name}
|
||||
className="w-12 h-12 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{vehicleInfo.name}</h1>
|
||||
<p className="text-lg text-gray-600">{catalogInfo.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Предупреждение об ошибке */}
|
||||
{hasError && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Предупреждение
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>Не удалось загрузить полную информацию об автомобиле. Отображается базовая информация.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отладочная информация */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
🔧 Отладочная информация
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Использовать localStorage:</span>
|
||||
<span className="ml-2 font-medium">{useStorage ? 'Да' : 'Нет'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Длина SSD из URL:</span>
|
||||
<span className="ml-2 font-medium">{ssdLengthFromUrl || 'не указана'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">SSD получен:</span>
|
||||
<span className="ml-2 font-medium">{finalSsd ? 'Да' : 'Нет'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Длина SSD:</span>
|
||||
<span className="ml-2 font-medium">{finalSsd.length}</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-gray-500">SSD (первые 100 символов):</span>
|
||||
<span className="ml-2 font-mono text-xs break-all">
|
||||
{finalSsd ? finalSsd.substring(0, 100) + '...' : 'отсутствует'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Характеристики автомобиля */}
|
||||
{vehicleInfo.attributes && vehicleInfo.attributes.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{vehicleInfo.attributes.map((attr, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-3">
|
||||
<dt className="text-sm font-medium text-gray-500">{attr.name}</dt>
|
||||
<dd className="text-sm text-gray-900 mt-1">{attr.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Способы поиска запчастей */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Поиск запчастей</h2>
|
||||
<p className="text-gray-600">
|
||||
Выберите способ поиска запчастей для вашего автомобиля
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Диагностический компонент */}
|
||||
<LaximoDiagnostic
|
||||
catalogCode={vehicleInfo.catalog}
|
||||
vehicleId={vehicleInfo.vehicleid}
|
||||
ssd={vehicleInfo.ssd}
|
||||
/>
|
||||
|
||||
<VehiclePartsSearchSection
|
||||
catalogInfo={catalogInfo}
|
||||
vehicleInfo={vehicleInfo}
|
||||
searchType={searchType}
|
||||
onSearchTypeChange={setSearchType}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehicleDetailsPage;
|
@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { GET_LAXIMO_CATALOG_INFO, SEARCH_LAXIMO_OEM } from '@/lib/graphql';
|
||||
import { LaximoCatalogInfo, LaximoOEMResult } from '@/types/laximo';
|
||||
|
||||
const InfoPartDetail = ({ brandName, oemNumber }: { brandName: string; oemNumber: string }) => (
|
||||
<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 text-[#000814] hover:text-[#EC1C24] transition-colors">
|
||||
<div>Главная</div>
|
||||
</a>
|
||||
<div className="text-block-3">→</div>
|
||||
<a href="#" className="link-block-2 w-inline-block text-[#000814] hover:text-[#EC1C24] transition-colors">
|
||||
<div>Каталог</div>
|
||||
</a>
|
||||
<div className="text-block-3">→</div>
|
||||
<div className="font-semibold text-gray-900">{brandName}</div>
|
||||
<div className="text-block-3">→</div>
|
||||
<div className="font-semibold text-gray-900">Деталь {oemNumber}</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-8 mt-4">
|
||||
<div className="w-layout-hflex flex-block-10 items-center gap-4">
|
||||
<h1 className="heading text-2xl font-bold text-gray-900">Деталь {oemNumber}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg text-gray-600 mt-2">
|
||||
Подробная информация о детали {oemNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const PartDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const { brand, vehicleId, oemNumber } = router.query;
|
||||
|
||||
// Получаем SSD из localStorage или URL
|
||||
const useStorage = router.query.use_storage === '1';
|
||||
const ssdLengthFromUrl = router.query.ssd_length ? parseInt(router.query.ssd_length as string) : 0;
|
||||
let finalSsd = '';
|
||||
|
||||
if (useStorage && typeof window !== 'undefined') {
|
||||
const vehicleKey = `vehicle_ssd_${brand}_${vehicleId}`;
|
||||
const storedSsd = localStorage.getItem(vehicleKey);
|
||||
if (storedSsd) {
|
||||
finalSsd = storedSsd;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем информацию о каталоге
|
||||
const { data: catalogData, loading: catalogLoading } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
|
||||
GET_LAXIMO_CATALOG_INFO,
|
||||
{
|
||||
variables: { catalogCode: brand },
|
||||
skip: !brand,
|
||||
errorPolicy: 'all',
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем информацию о детали
|
||||
const { data: oemData, loading: oemLoading, error: oemError } = useQuery<{ laximoOEMSearch: LaximoOEMResult }>(
|
||||
SEARCH_LAXIMO_OEM,
|
||||
{
|
||||
variables: {
|
||||
catalogCode: brand,
|
||||
vehicleId: vehicleId,
|
||||
oemNumber: oemNumber,
|
||||
ssd: finalSsd
|
||||
},
|
||||
skip: !brand || !vehicleId || !oemNumber || !finalSsd,
|
||||
errorPolicy: 'all'
|
||||
}
|
||||
);
|
||||
|
||||
if (!brand || !vehicleId || !oemNumber) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Деталь не найдена</title>
|
||||
</Head>
|
||||
<Header />
|
||||
<main style={{ minHeight: '100vh', backgroundColor: '#f9fafb' }}>
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#1f2937', marginBottom: '1rem' }}>
|
||||
Деталь не найдена
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', marginBottom: '2rem' }}>
|
||||
Неверные параметры для отображения детали
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
backgroundColor: '#dc2626',
|
||||
color: 'white',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const catalogInfo = catalogData?.laximoCatalogInfo;
|
||||
const oemResult = oemData?.laximoOEMSearch;
|
||||
|
||||
const totalUnits = oemResult?.categories.reduce((total, cat) => total + cat.units.length, 0) || 0;
|
||||
const totalDetails = oemResult?.categories.reduce((total, cat) =>
|
||||
total + cat.units.reduce((unitTotal, unit) => unitTotal + unit.details.length, 0), 0) || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Деталь {oemNumber} - {catalogInfo?.name || 'Каталог запчастей'}</title>
|
||||
<meta name="description" content={`Подробная информация о детали ${oemNumber} в каталоге ${catalogInfo?.name}`} />
|
||||
</Head>
|
||||
<Header />
|
||||
<div className="bg-[#F5F8FB] min-h-screen w-full">
|
||||
<InfoPartDetail brandName={catalogInfo?.name || String(brand)} oemNumber={String(oemNumber)} />
|
||||
<div className="flex flex-col px-32 pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="w-full max-w-[1200px]">
|
||||
{(catalogLoading || oemLoading) && (
|
||||
<div className="bg-white rounded-2xl shadow p-10 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-red-600 mb-6"></div>
|
||||
<p className="text-lg text-gray-600">Загружаем информацию о детали {oemNumber}...</p>
|
||||
</div>
|
||||
)}
|
||||
{oemError && !oemLoading && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl shadow p-10 mb-6">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-6 h-6 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-red-800">Ошибка загрузки</h3>
|
||||
<p className="text-red-700 mt-1">Не удалось загрузить информацию о детали: {oemError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{oemResult && oemResult.categories.length > 0 ? (
|
||||
<div className="bg-white rounded-2xl shadow p-10 flex flex-col gap-8">
|
||||
<div className="border-b border-gray-200 pb-4 mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Применимость в автомобиле</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">Показывает, в каких узлах и категориях используется данная деталь</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">OEM номер</div>
|
||||
<div className="text-lg font-mono font-semibold text-gray-900">{oemResult.oemNumber}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">Категорий</div>
|
||||
<div className="text-lg font-semibold text-gray-900">{oemResult.categories.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">Узлов</div>
|
||||
<div className="text-lg font-semibold text-gray-900">{totalUnits}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">Позиций</div>
|
||||
<div className="text-lg font-semibold text-gray-900">{totalDetails}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{oemResult.categories.map((category) => (
|
||||
<div key={category.categoryid} className="bg-gray-50 border border-gray-200 rounded-lg mb-6">
|
||||
<div className="bg-gray-100 border-b border-gray-200 p-4 rounded-t-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
📂 {category.name}
|
||||
<span className="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-medium">
|
||||
{category.units.length} узл{category.units.length === 1 ? '' : category.units.length < 5 ? 'а' : 'ов'}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{category.units.map((unit) => (
|
||||
<div key={unit.unitid} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex gap-4 mb-4">
|
||||
{unit.imageurl && (
|
||||
<img
|
||||
src={unit.imageurl.replace('%size%', '100')}
|
||||
alt={unit.name}
|
||||
className="w-16 h-16 object-contain border border-gray-200 rounded bg-white"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-1">🔧 {unit.name}</h4>
|
||||
{unit.code && (
|
||||
<p className="text-xs text-gray-500 font-mono">Код: {unit.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{unit.details.map((detail, index) => (
|
||||
<div key={`${detail.detailid}-${index}`} className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="text-base font-medium text-gray-900 mb-3">📄 {detail.name}</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-500">OEM номер:</span>
|
||||
<span className="ml-2 font-mono font-semibold text-[#EC1C24]">{detail.oem}</span>
|
||||
</div>
|
||||
{detail.brand && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-500">Бренд:</span>
|
||||
<span className="ml-2 font-semibold text-blue-700">{detail.brand}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.amount && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-500">Количество:</span>
|
||||
<span className="ml-2 bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-semibold">{detail.amount}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.range && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-500">Период:</span>
|
||||
<span className="ml-2 text-green-700">{detail.range}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{detail.attributes && detail.attributes.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-xs font-medium text-gray-500">Характеристики:</span>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{detail.attributes.map((attr, attrIndex) => (
|
||||
<div key={attrIndex} className="text-xs text-gray-500">
|
||||
<span className="font-medium">{attr.name || attr.key}:</span> <span>{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="bg-[#EC1C24] text-white px-4 py-2 rounded font-medium text-sm hover:bg-[#b91c1c] transition-colors">В корзину</button>
|
||||
<button className="bg-white text-gray-700 border border-gray-200 px-4 py-2 rounded font-medium text-sm hover:bg-gray-100 transition-colors">Найти аналоги</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !oemLoading && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-2xl shadow p-10 text-center">
|
||||
<svg className="w-16 h-16 text-yellow-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-yellow-800 mb-2">Деталь не найдена</h3>
|
||||
<p className="text-yellow-700 mb-4">По номеру "{oemNumber}" ничего не найдено в данном автомобиле. Проверьте правильность номера или вернитесь к поиску.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartDetailPage;
|
275
src/pages/vehicles-by-part.tsx
Normal file
275
src/pages/vehicles-by-part.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import Head from 'next/head';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { FIND_LAXIMO_VEHICLES_BY_PART_NUMBER } from '@/lib/graphql';
|
||||
import { LaximoVehiclesByPartResult, LaximoVehicleSearchResult } from '@/types/laximo';
|
||||
|
||||
const VehiclesByPartPage = () => {
|
||||
const router = useRouter();
|
||||
const { partNumber, catalogCode } = router.query;
|
||||
const [selectedCatalog, setSelectedCatalog] = useState<string>('all');
|
||||
|
||||
// Отладочная информация
|
||||
console.log('🔍 VehiclesByPartPage - URL параметры:', { partNumber, catalogCode });
|
||||
console.log('🔍 VehiclesByPartPage - Тип partNumber:', typeof partNumber, 'Значение:', partNumber);
|
||||
|
||||
// Очищаем артикул от лишних символов
|
||||
const cleanPartNumber = partNumber ? (partNumber as string).trim() : '';
|
||||
console.log('🔍 VehiclesByPartPage - Очищенный артикул:', cleanPartNumber);
|
||||
|
||||
// Запрос для поиска автомобилей по артикулу
|
||||
const { data, loading, error } = useQuery<{ laximoFindVehiclesByPartNumber: LaximoVehiclesByPartResult }>(
|
||||
FIND_LAXIMO_VEHICLES_BY_PART_NUMBER,
|
||||
{
|
||||
variables: { partNumber: cleanPartNumber },
|
||||
skip: !cleanPartNumber,
|
||||
errorPolicy: 'all'
|
||||
}
|
||||
);
|
||||
|
||||
const handleVehicleSelect = (vehicle: LaximoVehicleSearchResult) => {
|
||||
// Переходим сразу на страницу поиска результатов с артикулом
|
||||
console.log('🔍 Переход на поиск результатов с артикулом:', { partNumber: cleanPartNumber, vehicle: vehicle.name });
|
||||
|
||||
// Определяем бренд для поиска (используем первое слово из названия автомобиля или бренд)
|
||||
const vehicleBrand = vehicle.brand || vehicle.name?.split(' ')[0] || 'UNKNOWN';
|
||||
|
||||
// Переходим на search-result с артикулом
|
||||
const url = `/search-result?article=${encodeURIComponent(cleanPartNumber)}&brand=${encodeURIComponent(vehicleBrand)}`;
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Поиск автомобилей по артикулу {cleanPartNumber} - Protek</title>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">Поиск автомобилей...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data?.laximoFindVehiclesByPartNumber) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Ошибка поиска - Protek</title>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Ошибка поиска</h1>
|
||||
<p className="text-gray-600 mb-8">Не удалось найти автомобили по артикулу {cleanPartNumber}</p>
|
||||
<button
|
||||
onClick={handleBackToSearch}
|
||||
className="bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Назад к поиску
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const result = data.laximoFindVehiclesByPartNumber;
|
||||
|
||||
// Фильтруем каталоги по выбранному
|
||||
const filteredCatalogs = selectedCatalog === 'all'
|
||||
? result.catalogs
|
||||
: result.catalogs.filter(catalog => catalog.catalogCode === selectedCatalog);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Автомобили по артикулу {cleanPartNumber} - Protek</title>
|
||||
<meta name="description" content={`Найдено ${result.totalVehicles} автомобилей по артикулу ${cleanPartNumber} в ${result.catalogs.length} каталогах`} />
|
||||
</Head>
|
||||
<Header />
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Навигация */}
|
||||
<nav className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleBackToSearch}
|
||||
className="text-gray-500 hover:text-gray-700 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Главная</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>Поиск</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900 font-medium">Автомобили по артикулу {cleanPartNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Заголовок и статистика */}
|
||||
<div className="bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Автомобили по артикулу {cleanPartNumber}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mt-2">
|
||||
Найдено {result.totalVehicles} автомобилей в {result.catalogs.length} каталогах
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по каталогам */}
|
||||
{result.catalogs.length > 1 && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Каталог:</label>
|
||||
<select
|
||||
value={selectedCatalog}
|
||||
onChange={(e) => setSelectedCatalog(e.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="all">Все каталоги ({result.totalVehicles})</option>
|
||||
{result.catalogs.map((catalog) => (
|
||||
<option key={catalog.catalogCode} value={catalog.catalogCode}>
|
||||
{catalog.brand} ({catalog.vehicleCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список автомобилей */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="space-y-6">
|
||||
{filteredCatalogs.map((catalog) => (
|
||||
<div key={catalog.catalogCode} className="bg-white rounded-lg shadow-sm border">
|
||||
{/* Заголовок каталога */}
|
||||
<div className="px-6 py-4 border-b bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{catalog.brand}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-600">
|
||||
{catalog.vehicleCount} автомобилей
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список автомобилей в каталоге */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{catalog.vehicles.map((vehicle, index) => (
|
||||
<div
|
||||
key={`${vehicle.vehicleid}-${index}`}
|
||||
className="p-6 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => handleVehicleSelect(vehicle)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{vehicle.name || `${vehicle.brand} ${vehicle.model || 'Vehicle'}`}
|
||||
</h3>
|
||||
{vehicle.year && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{vehicle.year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{vehicle.modification && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Модификация:</span> {vehicle.modification}
|
||||
</p>
|
||||
)}
|
||||
{vehicle.engine && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Двигатель:</span> {vehicle.engine}
|
||||
</p>
|
||||
)}
|
||||
{vehicle.bodytype && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Кузов:</span> {vehicle.bodytype}
|
||||
</p>
|
||||
)}
|
||||
{(vehicle as any).transmission && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">КПП:</span> {(vehicle as any).transmission}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<button className="text-red-600 hover:text-red-700 font-medium text-sm">
|
||||
Выбрать автомобиль
|
||||
</button>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Пустое состояние */}
|
||||
{filteredCatalogs.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-12 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.29-1.009-5.824-2.562M15 6.306a7.962 7.962 0 00-6 0m6 0V3a1 1 0 00-1-1H8a1 1 0 00-1 1v3.306" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Автомобили не найдены
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
В выбранном каталоге нет автомобилей с артикулом {partNumber}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehiclesByPartPage;
|
51
src/pages/wholesale.tsx
Normal file
51
src/pages/wholesale.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import Head from "next/head";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CatalogSubscribe from "@/components/CatalogSubscribe";
|
||||
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
|
||||
import InfoWholesale from "@/components/wholesale/InfoWholesale";
|
||||
import DescWholesale from "@/components/wholesale/DescWholesale";
|
||||
import WhyWholesale from "@/components/wholesale/WhyWholesale";
|
||||
import ServiceWholesale from "@/components/wholesale/ServiceWholesale";
|
||||
import HowToBuy from "@/components/wholesale/HowToBuy";
|
||||
import Help from "@/components/Help";
|
||||
|
||||
|
||||
export default function Wholesale() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>wholesale</title>
|
||||
<meta content="wholesale" property="og:title" />
|
||||
<meta content="wholesale" property="twitter:title" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta content="Webflow" name="generator" />
|
||||
<link href="/css/normalize.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/protekproject.webflow.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
|
||||
<InfoWholesale />
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-hflex flex-block-67">
|
||||
<DescWholesale />
|
||||
<WhyWholesale />
|
||||
<ServiceWholesale />
|
||||
<HowToBuy />
|
||||
<Help />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-3">
|
||||
<CatalogSubscribe />
|
||||
</section>
|
||||
<Footer />
|
||||
<MobileMenuBottomSection />
|
||||
</>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user