@@ -10,6 +10,15 @@ const nextConfig = {
|
|||||||
// Use this to set Nx-specific options
|
// Use this to set Nx-specific options
|
||||||
// See: https://nx.dev/recipes/next/next-config-setup
|
// See: https://nx.dev/recipes/next/next-config-setup
|
||||||
nx: {},
|
nx: {},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/catalog/:category(gidravlika|pnevmatika|asu|zip)',
|
||||||
|
destination: '/catalog?category=:category',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
|
|||||||
58
apps/web/src/app/brand/[id]/catalog/page.tsx
Normal file
58
apps/web/src/app/brand/[id]/catalog/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { brands } from '@/data/brands';
|
||||||
|
import { products } from '@/data/products';
|
||||||
|
import { filterProducts, parseSearchParams } from '@/lib/filterProducts';
|
||||||
|
import { CatalogView } from '@/components/CatalogView/CatalogView';
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return brands.map((b) => ({ id: String(b.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const brand = brands.find((b) => b.id === Number(id));
|
||||||
|
return {
|
||||||
|
title: brand
|
||||||
|
? `Каталог ${brand.name} | PAN-PROM`
|
||||||
|
: 'Каталог | PAN-PROM',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BrandCatalogPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const brand = brands.find((b) => b.id === Number(id));
|
||||||
|
if (!brand) notFound();
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filters = parseSearchParams(sp);
|
||||||
|
const brandProducts = products.filter((p) => p.brand === brand.name);
|
||||||
|
const filtered = filterProducts(brandProducts, filters);
|
||||||
|
|
||||||
|
const basePath = `/brand/${id}/catalog`;
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Главная', href: '/' },
|
||||||
|
{ label: 'Каталог', href: '/catalog' },
|
||||||
|
{ label: brand.name, href: `/brand/${id}` },
|
||||||
|
{ label: 'Каталог' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CatalogView
|
||||||
|
products={filtered}
|
||||||
|
breadcrumbItems={breadcrumbItems}
|
||||||
|
basePath={basePath}
|
||||||
|
hiddenFilters={['brand']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,21 +14,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoBox {
|
.logoBox {
|
||||||
width: 64px;
|
width: 80px;
|
||||||
height: 64px;
|
height: 80px;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
background: var(--color-slate-100);
|
background: var(--color-slate-100);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 22px;
|
font-size: 26px;
|
||||||
color: var(--color-slate-600);
|
color: var(--color-slate-600);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandName {
|
.brandName {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-slate-900);
|
color: var(--color-slate-900);
|
||||||
}
|
}
|
||||||
@@ -39,17 +39,11 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.cardGrid {
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-slate-800);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -60,8 +54,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-muted);
|
}
|
||||||
padding: var(--space-2xl) 0;
|
|
||||||
|
.cardLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardValue {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-slate-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-slate-800);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-slate-50);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-slate-700);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-orange-50);
|
||||||
|
border-color: var(--color-orange-200);
|
||||||
|
color: var(--color-orange-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaBlock {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { brands } from '@/data/brands';
|
import { brands } from '@/data/brands';
|
||||||
import { products } from '@/data/products';
|
import { products } from '@/data/products';
|
||||||
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
|
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
|
||||||
import { ProductCard } from '@/components/ProductCard/ProductCard';
|
import { Button } from '@/components/ui/Button/Button';
|
||||||
import styles from './page.module.scss';
|
import styles from './page.module.scss';
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
@@ -31,6 +32,8 @@ export default async function BrandPage({
|
|||||||
if (!brand) notFound();
|
if (!brand) notFound();
|
||||||
|
|
||||||
const brandProducts = products.filter((p) => p.brand === brand.name);
|
const brandProducts = products.filter((p) => p.brand === brand.name);
|
||||||
|
const subcategories = [...new Set(brandProducts.map((p) => p.subcategory))];
|
||||||
|
const inStockCount = brandProducts.filter((p) => p.inStock).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -52,20 +55,47 @@ export default async function BrandPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className={styles.sectionTitle}>
|
<div className={styles.cardGrid}>
|
||||||
Продукция ({brandProducts.length})
|
<div className={styles.card}>
|
||||||
</h2>
|
<div className={styles.cardLabel}>Направление</div>
|
||||||
<div className={styles.grid}>
|
<div className={styles.cardValue}>{brand.category}</div>
|
||||||
{brandProducts.map((p) => (
|
</div>
|
||||||
<ProductCard key={p.id} product={p} />
|
<div className={styles.card}>
|
||||||
))}
|
<div className={styles.cardLabel}>Страна</div>
|
||||||
|
<div className={styles.cardValue}>{brand.country}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.cardLabel}>Позиций в каталоге</div>
|
||||||
|
<div className={styles.cardValue}>{brandProducts.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.cardLabel}>В наличии</div>
|
||||||
|
<div className={styles.cardValue}>{inStockCount}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{brandProducts.length === 0 && (
|
{subcategories.length > 0 && (
|
||||||
<p className={styles.empty}>
|
<>
|
||||||
Продукция этого бренда скоро появится в каталоге.
|
<h2 className={styles.sectionTitle}>Категории продукции</h2>
|
||||||
</p>
|
<div className={styles.tagGrid}>
|
||||||
|
{subcategories.map((sub) => (
|
||||||
|
<Link
|
||||||
|
key={sub}
|
||||||
|
href={`/brand/${id}/catalog?subcategory=${encodeURIComponent(sub)}`}
|
||||||
|
className={styles.tag}
|
||||||
|
>
|
||||||
|
{sub}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={styles.ctaBlock}>
|
||||||
|
<Link href={`/brand/${id}/catalog`}>
|
||||||
|
<Button variant="primary">Открыть каталог {brand.name}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function CartPage() {
|
|||||||
Позиций: {cart.length}
|
Позиций: {cart.length}
|
||||||
</span>
|
</span>
|
||||||
<Link href="/rfq">
|
<Link href="/rfq">
|
||||||
<Button>Отправить запрос (RFQ)</Button>
|
<Button>Отправить запрос</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { products } from '@/data/products';
|
|
||||||
import { categories } from '@/data/categories';
|
|
||||||
import { categoryNameMap, CategorySlug } from '@/types';
|
|
||||||
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
|
|
||||||
import { CatalogSidebar } from '@/components/CatalogSidebar/CatalogSidebar';
|
|
||||||
import { ProductCard } from '@/components/ProductCard/ProductCard';
|
|
||||||
import styles from '../page.module.scss';
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return categories.map((c) => ({ category: c.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ category: string }>;
|
|
||||||
}) {
|
|
||||||
return params.then(({ category }) => {
|
|
||||||
const name = categoryNameMap[category as CategorySlug];
|
|
||||||
return { title: name ? `${name} | PAN-PROM` : 'Каталог | PAN-PROM' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function CategoryPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ category: string }>;
|
|
||||||
}) {
|
|
||||||
const { category } = await params;
|
|
||||||
const catName = categoryNameMap[category as CategorySlug];
|
|
||||||
if (!catName) notFound();
|
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.category === catName);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.layout}>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<CatalogSidebar activeCategory={category} />
|
|
||||||
</Suspense>
|
|
||||||
<div className={styles.main}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
{ label: 'Главная', href: '/' },
|
|
||||||
{ label: 'Каталог', href: '/catalog' },
|
|
||||||
{ label: catName },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<span className={styles.count}>{filtered.length} позиций</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{filtered.map((p) => (
|
|
||||||
<ProductCard key={p.id} product={p} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,41 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
import { products } from '@/data/products';
|
import { products } from '@/data/products';
|
||||||
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
|
import { categoryNameMap, CategorySlug } from '@/types';
|
||||||
import { CatalogSidebar } from '@/components/CatalogSidebar/CatalogSidebar';
|
import { filterProducts, parseSearchParams } from '@/lib/filterProducts';
|
||||||
import { ProductCard } from '@/components/ProductCard/ProductCard';
|
import { CatalogView } from '@/components/CatalogView/CatalogView';
|
||||||
import styles from './page.module.scss';
|
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata({
|
||||||
title: 'Каталог | PAN-PROM',
|
searchParams,
|
||||||
};
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const category = params.category as string | undefined;
|
||||||
|
const catName = category
|
||||||
|
? categoryNameMap[category as CategorySlug]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const title = catName
|
||||||
|
? `${catName} | Каталог | PAN-PROM`
|
||||||
|
: 'Каталог | PAN-PROM';
|
||||||
|
|
||||||
|
return { title };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CatalogPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const filters = parseSearchParams(params);
|
||||||
|
const filtered = filterProducts(products, filters);
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Главная', href: '/' },
|
||||||
|
{ label: 'Каталог' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function CatalogPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.layout}>
|
<CatalogView products={filtered} breadcrumbItems={breadcrumbItems} />
|
||||||
<Suspense fallback={null}>
|
|
||||||
<CatalogSidebar />
|
|
||||||
</Suspense>
|
|
||||||
<div className={styles.main}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
{ label: 'Главная', href: '/' },
|
|
||||||
{ label: 'Каталог' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<span className={styles.count}>{products.length} позиций</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{products.map((p) => (
|
|
||||||
<ProductCard key={p.id} product={p} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { RFQForm } from '@/components/RFQForm/RFQForm';
|
|||||||
import styles from './page.module.scss';
|
import styles from './page.module.scss';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Запрос цены (RFQ) | PAN-PROM',
|
title: 'Запрос цены | PAN-PROM',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RFQPage() {
|
export default function RFQPage() {
|
||||||
@@ -12,7 +12,7 @@ export default function RFQPage() {
|
|||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Главная', href: '/' },
|
{ label: 'Главная', href: '/' },
|
||||||
{ label: 'Запрос цены (RFQ)' },
|
{ label: 'Запрос цены' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<h1 className={styles.title}>Запрос коммерческого предложения</h1>
|
<h1 className={styles.title}>Запрос коммерческого предложения</h1>
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { Icon } from '@/components/Icon/Icon';
|
import { Icon } from '@/components/Icon/Icon';
|
||||||
import { categories } from '@/data/categories';
|
import { categories } from '@/data/categories';
|
||||||
import { brands } from '@/data/brands';
|
import { brands } from '@/data/brands';
|
||||||
|
import { products } from '@/data/products';
|
||||||
|
import { useCatalogFilters } from '@/hooks/useCatalogFilters';
|
||||||
|
import { getSubcategories } from '@/lib/getSubcategories';
|
||||||
import styles from './CatalogSidebar.module.scss';
|
import styles from './CatalogSidebar.module.scss';
|
||||||
|
|
||||||
interface CatalogSidebarProps {
|
interface CatalogSidebarProps {
|
||||||
activeCategory?: string;
|
basePath?: string;
|
||||||
|
hiddenFilters?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogSidebar({ activeCategory }: CatalogSidebarProps) {
|
export function CatalogSidebar({
|
||||||
const router = useRouter();
|
basePath = '/catalog',
|
||||||
const searchParams = useSearchParams();
|
hiddenFilters = [],
|
||||||
const activeBrand = searchParams.get('brand');
|
}: CatalogSidebarProps) {
|
||||||
|
const { filters, setFilter } = useCatalogFilters(basePath);
|
||||||
|
|
||||||
const handleCategoryClick = (slug: string | null) => {
|
const toggleFilter = (
|
||||||
if (slug) {
|
key: 'category' | 'brand' | 'subcategory' | 'sort',
|
||||||
router.push(`/catalog/${slug}`);
|
value: string
|
||||||
} else {
|
) => {
|
||||||
router.push('/catalog');
|
setFilter(key, filters[key] === value ? undefined : value);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrandClick = (brandId: number) => {
|
const filteredBrands = filters.category
|
||||||
router.push(`/brand/${brandId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredBrands = activeCategory
|
|
||||||
? brands.filter((b) => {
|
? brands.filter((b) => {
|
||||||
const cat = categories.find((c) => c.slug === activeCategory);
|
const cat = categories.find((c) => c.slug === filters.category);
|
||||||
return cat ? b.category === cat.name : true;
|
return cat ? b.category === cat.name : true;
|
||||||
})
|
})
|
||||||
: brands;
|
: brands;
|
||||||
|
|
||||||
|
const subcategories = getSubcategories(products, filters.category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
@@ -42,35 +43,84 @@ export function CatalogSidebar({ activeCategory }: CatalogSidebarProps) {
|
|||||||
Фильтры
|
Фильтры
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<div className={styles.filterLabel}>Направление</div>
|
<div className={styles.filterLabel}>Направление</div>
|
||||||
<button
|
<button
|
||||||
className={`${styles.filterItem} ${!activeCategory ? styles.filterItemActive : ''}`}
|
className={`${styles.filterItem} ${!filters.category ? styles.filterItemActive : ''}`}
|
||||||
onClick={() => handleCategoryClick(null)}
|
onClick={() => setFilter('category', undefined)}
|
||||||
>
|
>
|
||||||
Все
|
Все
|
||||||
</button>
|
</button>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.slug}
|
key={c.slug}
|
||||||
className={`${styles.filterItem} ${activeCategory === c.slug ? styles.filterItemActive : ''}`}
|
className={`${styles.filterItem} ${filters.category === c.slug ? styles.filterItemActive : ''}`}
|
||||||
onClick={() => handleCategoryClick(c.slug)}
|
onClick={() => toggleFilter('category', c.slug)}
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Brand */}
|
||||||
|
{!hiddenFilters.includes('brand') && (
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<div className={styles.filterLabel}>Бренд</div>
|
||||||
|
{filteredBrands.slice(0, 6).map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
className={`${styles.filterItem} ${filters.brand === b.name ? styles.filterItemActive : ''}`}
|
||||||
|
onClick={() => toggleFilter('brand', b.name)}
|
||||||
|
>
|
||||||
|
<span>{b.name}</span>
|
||||||
|
<span className={styles.brandCountry}>{b.country}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subcategory */}
|
||||||
|
{subcategories.length > 0 && (
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<div className={styles.filterLabel}>Подкатегория</div>
|
||||||
|
{subcategories.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={sub}
|
||||||
|
className={`${styles.filterItem} ${filters.subcategory === sub ? styles.filterItemActive : ''}`}
|
||||||
|
onClick={() => toggleFilter('subcategory', sub)}
|
||||||
|
>
|
||||||
|
{sub}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In Stock */}
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<div className={styles.filterLabel}>Бренд</div>
|
<div className={styles.filterLabel}>Наличие</div>
|
||||||
{filteredBrands.slice(0, 6).map((b) => (
|
<button
|
||||||
|
className={`${styles.filterItem} ${filters.inStock ? styles.filterItemActive : ''}`}
|
||||||
|
onClick={() => setFilter('inStock', filters.inStock ? undefined : true)}
|
||||||
|
>
|
||||||
|
В наличии
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<div className={styles.filterLabel}>Сортировка</div>
|
||||||
|
{[
|
||||||
|
{ value: 'name', label: 'По названию' },
|
||||||
|
{ value: 'brand', label: 'По бренду' },
|
||||||
|
{ value: 'category', label: 'По категории' },
|
||||||
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={b.id}
|
key={opt.value}
|
||||||
className={`${styles.filterItem} ${activeBrand === String(b.id) ? styles.filterItemActive : ''}`}
|
className={`${styles.filterItem} ${filters.sort === opt.value ? styles.filterItemActive : ''}`}
|
||||||
onClick={() => handleBrandClick(b.id)}
|
onClick={() => toggleFilter('sort', opt.value)}
|
||||||
>
|
>
|
||||||
<span>{b.name}</span>
|
{opt.label}
|
||||||
<span className={styles.brandCountry}>{b.country}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,3 +35,9 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-2xl) 0;
|
||||||
|
}
|
||||||
49
apps/web/src/components/CatalogView/CatalogView.tsx
Normal file
49
apps/web/src/components/CatalogView/CatalogView.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import { Product } from '@/types';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
|
||||||
|
import { CatalogSidebar } from '@/components/CatalogSidebar/CatalogSidebar';
|
||||||
|
import { ProductCard } from '@/components/ProductCard/ProductCard';
|
||||||
|
import styles from './CatalogView.module.scss';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogViewProps {
|
||||||
|
products: Product[];
|
||||||
|
breadcrumbItems: BreadcrumbItem[];
|
||||||
|
basePath?: string;
|
||||||
|
hiddenFilters?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CatalogView({
|
||||||
|
products,
|
||||||
|
breadcrumbItems,
|
||||||
|
basePath = '/catalog',
|
||||||
|
hiddenFilters,
|
||||||
|
}: CatalogViewProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CatalogSidebar basePath={basePath} hiddenFilters={hiddenFilters} />
|
||||||
|
</Suspense>
|
||||||
|
<div className={styles.main}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
<span className={styles.count}>{products.length} позиций</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{products.map((p) => (
|
||||||
|
<ProductCard key={p.id} product={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{products.length === 0 && (
|
||||||
|
<p className={styles.empty}>
|
||||||
|
По выбранным фильтрам ничего не найдено.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function Directions() {
|
|||||||
{categories.map((d) => (
|
{categories.map((d) => (
|
||||||
<Link
|
<Link
|
||||||
key={d.slug}
|
key={d.slug}
|
||||||
href={`/catalog/${d.slug}`}
|
href={`/catalog?category=${d.slug}`}
|
||||||
className={styles.card}
|
className={styles.card}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function Header() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/rfq" className={styles.rfqButton}>
|
<Link href="/rfq" className={styles.rfqButton}>
|
||||||
Запрос цены (RFQ)
|
Запрос цены
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className={styles.menuToggle}
|
className={styles.menuToggle}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Hero() {
|
|||||||
<Icon name="chevronRight" size={16} color="#fff" />
|
<Icon name="chevronRight" size={16} color="#fff" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/rfq" className={styles.ctaSecondary}>
|
<Link href="/rfq" className={styles.ctaSecondary}>
|
||||||
Отправить запрос (RFQ)
|
Отправить запрос
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ProductDetail({ product }: { product: Product }) {
|
|||||||
В корзину
|
В корзину
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/rfq">
|
<Link href="/rfq">
|
||||||
<Button variant="secondary">Запросить цену (RFQ)</Button>
|
<Button variant="secondary">Запросить цену</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export const footerColumns = [
|
|||||||
{
|
{
|
||||||
title: 'Направления',
|
title: 'Направления',
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Гидравлика', href: '/catalog/gidravlika' },
|
{ label: 'Гидравлика', href: '/catalog?category=gidravlika' },
|
||||||
{ label: 'Пневматика', href: '/catalog/pnevmatika' },
|
{ label: 'Пневматика', href: '/catalog?category=pnevmatika' },
|
||||||
{ label: 'АСУ', href: '/catalog/asu' },
|
{ label: 'АСУ', href: '/catalog?category=asu' },
|
||||||
{ label: 'ЗИП', href: '/catalog/zip' },
|
{ label: 'ЗИП', href: '/catalog?category=zip' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -21,7 +21,7 @@ export const footerColumns = [
|
|||||||
links: [
|
links: [
|
||||||
{ label: 'О компании', href: '/about' },
|
{ label: 'О компании', href: '/about' },
|
||||||
{ label: 'Контакты', href: '/contact' },
|
{ label: 'Контакты', href: '/contact' },
|
||||||
{ label: 'Запрос цены (RFQ)', href: '/rfq' },
|
{ label: 'Запрос цены', href: '/rfq' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
50
apps/web/src/hooks/useCatalogFilters.ts
Normal file
50
apps/web/src/hooks/useCatalogFilters.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { CatalogFilters } from '@/lib/filterProducts';
|
||||||
|
|
||||||
|
export function useCatalogFilters(basePath = '/catalog') {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const filters: CatalogFilters = {
|
||||||
|
category: searchParams.get('category') || undefined,
|
||||||
|
brand: searchParams.get('brand') || undefined,
|
||||||
|
subcategory: searchParams.get('subcategory') || undefined,
|
||||||
|
inStock: searchParams.get('inStock') === 'true' ? true : undefined,
|
||||||
|
q: searchParams.get('q') || undefined,
|
||||||
|
sort: searchParams.get('sort') || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilterUrl = useCallback(
|
||||||
|
(overrides: Partial<CatalogFilters>) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const merged = { ...filters, ...overrides };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(merged)) {
|
||||||
|
if (value !== undefined && value !== null && value !== false) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `${basePath}?${qs}` : basePath;
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[basePath, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFilter = useCallback(
|
||||||
|
(key: keyof CatalogFilters, value: string | boolean | undefined) => {
|
||||||
|
router.push(buildFilterUrl({ [key]: value }));
|
||||||
|
},
|
||||||
|
[router, buildFilterUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
router.push(basePath);
|
||||||
|
}, [router, basePath]);
|
||||||
|
|
||||||
|
return { filters, setFilter, clearFilters, buildFilterUrl };
|
||||||
|
}
|
||||||
76
apps/web/src/lib/filterProducts.ts
Normal file
76
apps/web/src/lib/filterProducts.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Product } from '@/types';
|
||||||
|
import { categoryNameMap, CategorySlug } from '@/types';
|
||||||
|
|
||||||
|
export interface CatalogFilters {
|
||||||
|
category?: string;
|
||||||
|
brand?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
q?: string;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchParams(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>
|
||||||
|
): CatalogFilters {
|
||||||
|
return {
|
||||||
|
category: (searchParams.category as string) || undefined,
|
||||||
|
brand: (searchParams.brand as string) || undefined,
|
||||||
|
subcategory: (searchParams.subcategory as string) || undefined,
|
||||||
|
inStock: searchParams.inStock === 'true' ? true : undefined,
|
||||||
|
q: (searchParams.q as string) || undefined,
|
||||||
|
sort: (searchParams.sort as string) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterProducts(
|
||||||
|
products: Product[],
|
||||||
|
filters: CatalogFilters
|
||||||
|
): Product[] {
|
||||||
|
let result = [...products];
|
||||||
|
|
||||||
|
if (filters.category) {
|
||||||
|
const catName = categoryNameMap[filters.category as CategorySlug];
|
||||||
|
if (catName) {
|
||||||
|
result = result.filter((p) => p.category === catName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.brand) {
|
||||||
|
result = result.filter((p) => p.brand === filters.brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.subcategory) {
|
||||||
|
result = result.filter((p) => p.subcategory === filters.subcategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.inStock) {
|
||||||
|
result = result.filter((p) => p.inStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.q) {
|
||||||
|
const query = filters.q.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(query) ||
|
||||||
|
p.sku.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.sort) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
switch (filters.sort) {
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case 'brand':
|
||||||
|
return a.brand.localeCompare(b.brand);
|
||||||
|
case 'category':
|
||||||
|
return a.category.localeCompare(b.category);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
19
apps/web/src/lib/getSubcategories.ts
Normal file
19
apps/web/src/lib/getSubcategories.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Product } from '@/types';
|
||||||
|
import { categoryNameMap, CategorySlug } from '@/types';
|
||||||
|
|
||||||
|
export function getSubcategories(
|
||||||
|
products: Product[],
|
||||||
|
categorySlug?: string
|
||||||
|
): string[] {
|
||||||
|
let filtered = products;
|
||||||
|
|
||||||
|
if (categorySlug) {
|
||||||
|
const catName = categoryNameMap[categorySlug as CategorySlug];
|
||||||
|
if (catName) {
|
||||||
|
filtered = products.filter((p) => p.category === catName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcategories = [...new Set(filtered.map((p) => p.subcategory))];
|
||||||
|
return subcategories.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user