diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 6a59102..28dc5e8 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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 = [ diff --git a/apps/web/src/app/brand/[id]/catalog/page.tsx b/apps/web/src/app/brand/[id]/catalog/page.tsx new file mode 100644 index 0000000..95939fe --- /dev/null +++ b/apps/web/src/app/brand/[id]/catalog/page.tsx @@ -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>; +}) { + 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 ( + + ); +} diff --git a/apps/web/src/app/brand/[id]/page.module.scss b/apps/web/src/app/brand/[id]/page.module.scss index 44b4580..e07a59b 100644 --- a/apps/web/src/app/brand/[id]/page.module.scss +++ b/apps/web/src/app/brand/[id]/page.module.scss @@ -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; } diff --git a/apps/web/src/app/brand/[id]/page.tsx b/apps/web/src/app/brand/[id]/page.tsx index 35b945c..8ac1bcd 100644 --- a/apps/web/src/app/brand/[id]/page.tsx +++ b/apps/web/src/app/brand/[id]/page.tsx @@ -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 (
@@ -52,20 +55,47 @@ export default async function BrandPage({
-

- Продукция ({brandProducts.length}) -

-
- {brandProducts.map((p) => ( - - ))} +
+
+
Направление
+
{brand.category}
+
+
+
Страна
+
{brand.country}
+
+
+
Позиций в каталоге
+
{brandProducts.length}
+
+
+
В наличии
+
{inStockCount}
+
- {brandProducts.length === 0 && ( -

- Продукция этого бренда скоро появится в каталоге. -

+ {subcategories.length > 0 && ( + <> +

Категории продукции

+
+ {subcategories.map((sub) => ( + + {sub} + + ))} +
+ )} + +
+ + + +
); } diff --git a/apps/web/src/app/cart/page.tsx b/apps/web/src/app/cart/page.tsx index 0508764..936e213 100644 --- a/apps/web/src/app/cart/page.tsx +++ b/apps/web/src/app/cart/page.tsx @@ -68,7 +68,7 @@ export default function CartPage() { Позиций: {cart.length} - + diff --git a/apps/web/src/app/catalog/[category]/page.tsx b/apps/web/src/app/catalog/[category]/page.tsx deleted file mode 100644 index 45ba61b..0000000 --- a/apps/web/src/app/catalog/[category]/page.tsx +++ /dev/null @@ -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 ( -
- - - -
-
- - {filtered.length} позиций -
-
- {filtered.map((p) => ( - - ))} -
-
-
- ); -} diff --git a/apps/web/src/app/catalog/page.tsx b/apps/web/src/app/catalog/page.tsx index 5e43059..6cf1d1a 100644 --- a/apps/web/src/app/catalog/page.tsx +++ b/apps/web/src/app/catalog/page.tsx @@ -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>; +}) { + 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>; +}) { + const params = await searchParams; + const filters = parseSearchParams(params); + const filtered = filterProducts(products, filters); + + const breadcrumbItems = [ + { label: 'Главная', href: '/' }, + { label: 'Каталог' }, + ]; -export default function CatalogPage() { return ( -
- - - -
-
- - {products.length} позиций -
-
- {products.map((p) => ( - - ))} -
-
-
+ ); } diff --git a/apps/web/src/app/rfq/page.tsx b/apps/web/src/app/rfq/page.tsx index dc26e9c..b81cd89 100644 --- a/apps/web/src/app/rfq/page.tsx +++ b/apps/web/src/app/rfq/page.tsx @@ -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() {

Запрос коммерческого предложения

diff --git a/apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx b/apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx index a2e71ab..dae5360 100644 --- a/apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx +++ b/apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx @@ -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 (