Другой роутинг
Some checks failed
CI / main (push) Has been cancelled

This commit is contained in:
Igor Rybakov
2026-03-06 23:24:16 +02:00
parent 6228624805
commit f919ea66f6
19 changed files with 500 additions and 160 deletions

View File

@@ -10,6 +10,15 @@ const nextConfig = {
// Use this to set Nx-specific options
// See: https://nx.dev/recipes/next/next-config-setup
nx: {},
async redirects() {
return [
{
source: '/catalog/:category(gidravlika|pnevmatika|asu|zip)',
destination: '/catalog?category=:category',
permanent: true,
},
];
},
};
const plugins = [

View 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']}
/>
);
}

View File

@@ -14,21 +14,21 @@
}
.logoBox {
width: 64px;
height: 64px;
width: 80px;
height: 80px;
border-radius: var(--border-radius-lg);
background: var(--color-slate-100);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 22px;
font-size: 26px;
color: var(--color-slate-600);
flex-shrink: 0;
}
.brandName {
font-size: 28px;
font-size: 32px;
font-weight: 700;
color: var(--color-slate-900);
}
@@ -39,17 +39,11 @@
margin-top: 4px;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
color: var(--color-slate-800);
margin-bottom: var(--space-md);
}
.grid {
.cardGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-xl);
@media (max-width: 1024px) {
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;
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;
}

View File

@@ -1,8 +1,9 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { brands } from '@/data/brands';
import { products } from '@/data/products';
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';
export function generateStaticParams() {
@@ -31,6 +32,8 @@ export default async function BrandPage({
if (!brand) notFound();
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 (
<div className={styles.container}>
@@ -52,20 +55,47 @@ export default async function BrandPage({
</div>
</div>
<h2 className={styles.sectionTitle}>
Продукция ({brandProducts.length})
</h2>
<div className={styles.grid}>
{brandProducts.map((p) => (
<ProductCard key={p.id} product={p} />
))}
<div className={styles.cardGrid}>
<div className={styles.card}>
<div className={styles.cardLabel}>Направление</div>
<div className={styles.cardValue}>{brand.category}</div>
</div>
<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>
{brandProducts.length === 0 && (
<p className={styles.empty}>
Продукция этого бренда скоро появится в каталоге.
</p>
{subcategories.length > 0 && (
<>
<h2 className={styles.sectionTitle}>Категории продукции</h2>
<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>
);
}

View File

@@ -68,7 +68,7 @@ export default function CartPage() {
Позиций: {cart.length}
</span>
<Link href="/rfq">
<Button>Отправить запрос (RFQ)</Button>
<Button>Отправить запрос</Button>
</Link>
</div>
</>

View File

@@ -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>
);
}

View File

@@ -1,36 +1,41 @@
import { Suspense } from 'react';
import { products } from '@/data/products';
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
import { CatalogSidebar } from '@/components/CatalogSidebar/CatalogSidebar';
import { ProductCard } from '@/components/ProductCard/ProductCard';
import styles from './page.module.scss';
import { categoryNameMap, CategorySlug } from '@/types';
import { filterProducts, parseSearchParams } from '@/lib/filterProducts';
import { CatalogView } from '@/components/CatalogView/CatalogView';
export const metadata = {
title: 'Каталог | PAN-PROM',
};
export async function generateMetadata({
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 (
<div className={styles.layout}>
<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>
<CatalogView products={filtered} breadcrumbItems={breadcrumbItems} />
);
}

View File

@@ -3,7 +3,7 @@ import { RFQForm } from '@/components/RFQForm/RFQForm';
import styles from './page.module.scss';
export const metadata = {
title: 'Запрос цены (RFQ) | PAN-PROM',
title: 'Запрос цены | PAN-PROM',
};
export default function RFQPage() {
@@ -12,7 +12,7 @@ export default function RFQPage() {
<Breadcrumb
items={[
{ label: 'Главная', href: '/' },
{ label: 'Запрос цены (RFQ)' },
{ label: 'Запрос цены' },
]}
/>
<h1 className={styles.title}>Запрос коммерческого предложения</h1>

View File

@@ -1,39 +1,40 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Icon } from '@/components/Icon/Icon';
import { categories } from '@/data/categories';
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';
interface CatalogSidebarProps {
activeCategory?: string;
basePath?: string;
hiddenFilters?: string[];
}
export function CatalogSidebar({ activeCategory }: CatalogSidebarProps) {
const router = useRouter();
const searchParams = useSearchParams();
const activeBrand = searchParams.get('brand');
export function CatalogSidebar({
basePath = '/catalog',
hiddenFilters = [],
}: CatalogSidebarProps) {
const { filters, setFilter } = useCatalogFilters(basePath);
const handleCategoryClick = (slug: string | null) => {
if (slug) {
router.push(`/catalog/${slug}`);
} else {
router.push('/catalog');
}
const toggleFilter = (
key: 'category' | 'brand' | 'subcategory' | 'sort',
value: string
) => {
setFilter(key, filters[key] === value ? undefined : value);
};
const handleBrandClick = (brandId: number) => {
router.push(`/brand/${brandId}`);
};
const filteredBrands = activeCategory
const filteredBrands = filters.category
? 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;
})
: brands;
const subcategories = getSubcategories(products, filters.category);
return (
<aside className={styles.sidebar}>
<div className={styles.inner}>
@@ -42,35 +43,84 @@ export function CatalogSidebar({ activeCategory }: CatalogSidebarProps) {
Фильтры
</div>
{/* Category */}
<div className={styles.filterGroup}>
<div className={styles.filterLabel}>Направление</div>
<button
className={`${styles.filterItem} ${!activeCategory ? styles.filterItemActive : ''}`}
onClick={() => handleCategoryClick(null)}
className={`${styles.filterItem} ${!filters.category ? styles.filterItemActive : ''}`}
onClick={() => setFilter('category', undefined)}
>
Все
</button>
{categories.map((c) => (
<button
key={c.slug}
className={`${styles.filterItem} ${activeCategory === c.slug ? styles.filterItemActive : ''}`}
onClick={() => handleCategoryClick(c.slug)}
className={`${styles.filterItem} ${filters.category === c.slug ? styles.filterItemActive : ''}`}
onClick={() => toggleFilter('category', c.slug)}
>
{c.name}
</button>
))}
</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.filterLabel}>Бренд</div>
{filteredBrands.slice(0, 6).map((b) => (
<div className={styles.filterLabel}>Наличие</div>
<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
key={b.id}
className={`${styles.filterItem} ${activeBrand === String(b.id) ? styles.filterItemActive : ''}`}
onClick={() => handleBrandClick(b.id)}
key={opt.value}
className={`${styles.filterItem} ${filters.sort === opt.value ? styles.filterItemActive : ''}`}
onClick={() => toggleFilter('sort', opt.value)}
>
<span>{b.name}</span>
<span className={styles.brandCountry}>{b.country}</span>
{opt.label}
</button>
))}
</div>

View File

@@ -35,3 +35,9 @@
grid-template-columns: 1fr;
}
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-2xl) 0;
}

View 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>
);
}

View File

@@ -13,7 +13,7 @@ export function Directions() {
{categories.map((d) => (
<Link
key={d.slug}
href={`/catalog/${d.slug}`}
href={`/catalog?category=${d.slug}`}
className={styles.card}
>
<div

View File

@@ -117,7 +117,7 @@ export function Header() {
)}
</Link>
<Link href="/rfq" className={styles.rfqButton}>
Запрос цены (RFQ)
Запрос цены
</Link>
<button
className={styles.menuToggle}

View File

@@ -38,7 +38,7 @@ export function Hero() {
<Icon name="chevronRight" size={16} color="#fff" />
</Link>
<Link href="/rfq" className={styles.ctaSecondary}>
Отправить запрос (RFQ)
Отправить запрос
</Link>
</div>
</div>

View File

@@ -48,7 +48,7 @@ export function ProductDetail({ product }: { product: Product }) {
В корзину
</Button>
<Link href="/rfq">
<Button variant="secondary">Запросить цену (RFQ)</Button>
<Button variant="secondary">Запросить цену</Button>
</Link>
</div>
</div>

View File

@@ -10,10 +10,10 @@ export const footerColumns = [
{
title: 'Направления',
links: [
{ label: 'Гидравлика', href: '/catalog/gidravlika' },
{ label: 'Пневматика', href: '/catalog/pnevmatika' },
{ label: 'АСУ', href: '/catalog/asu' },
{ label: 'ЗИП', href: '/catalog/zip' },
{ label: 'Гидравлика', href: '/catalog?category=gidravlika' },
{ label: 'Пневматика', href: '/catalog?category=pnevmatika' },
{ label: 'АСУ', href: '/catalog?category=asu' },
{ label: 'ЗИП', href: '/catalog?category=zip' },
],
},
{
@@ -21,7 +21,7 @@ export const footerColumns = [
links: [
{ label: 'О компании', href: '/about' },
{ label: 'Контакты', href: '/contact' },
{ label: 'Запрос цены (RFQ)', href: '/rfq' },
{ label: 'Запрос цены', href: '/rfq' },
],
},
{

View 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 };
}

View 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;
}

View 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));
}