Перенес mockup данные на бэкенд
Some checks failed
CI / main (push) Has been cancelled

This commit is contained in:
Igor Rybakov
2026-03-06 23:51:52 +02:00
parent b9918116ae
commit cb527f4961
35 changed files with 468 additions and 85 deletions

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BrandsModule } from './brands/brands.module';
import { ProductsModule } from './products/products.module';
import { CategoriesModule } from './categories/categories.module';
@Module({
imports: [],
imports: [BrandsModule, ProductsModule, CategoriesModule],
controllers: [AppController],
providers: [AppService],
})

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { BrandsService } from './brands.service';
@Controller('brands')
export class BrandsController {
constructor(private readonly brandsService: BrandsService) {}
@Get()
findAll() {
return this.brandsService.findAll();
}
}

View File

@@ -1,4 +1,4 @@
import { Brand } from '@/types';
import { Brand } from '@spairtech/shared';
export const brands: Brand[] = [
{ id: 1, name: 'Bosch Rexroth', country: 'DE', logo: 'BR', category: 'Гидравлика' },

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
controllers: [BrandsController],
providers: [BrandsService],
})
export class BrandsModule {}

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { brands } from './brands.data';
@Injectable()
export class BrandsService {
async findAll() {
await new Promise((resolve) => setTimeout(resolve, 1000));
return brands;
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
findAll() {
return this.categoriesService.findAll();
}
}

View File

@@ -1,4 +1,4 @@
import { Category } from '@/types';
import { Category } from '@spairtech/shared';
export const categories: Category[] = [
{
@@ -34,5 +34,3 @@ export const categories: Category[] = [
color: '#7B1FA2',
},
];
export const allCategories = ['Все', ...categories.map((c) => c.name)];

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { categories } from './categories.data';
@Injectable()
export class CategoriesService {
async findAll() {
await new Promise((resolve) => setTimeout(resolve, 1000));
return categories;
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll() {
return this.productsService.findAll();
}
}

View File

@@ -1,4 +1,4 @@
import { Product } from '@/types';
import { Product } from '@spairtech/shared';
export const products: Product[] = [
{ id: 1, sku: '4WRPEH 6 C3B12L', name: 'Пропорциональный распределитель', brand: 'Bosch Rexroth', category: 'Гидравлика', subcategory: 'Распределители', price: 'По запросу', image: '', inStock: true },

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { products } from './products.data';
@Injectable()
export class ProductsService {
async findAll() {
await new Promise((resolve) => setTimeout(resolve, 1000));
return products;
}
}

View File

@@ -9,6 +9,7 @@ import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;

View File

@@ -20,5 +20,10 @@
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
],
"references": [
{
"path": "../../libs/shared/tsconfig.lib.json"
}
]
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -2,6 +2,15 @@
"name": "@spairtech/web",
"version": "0.0.1",
"private": true,
"nx": {
"targets": {
"dev": {
"options": {
"port": 8888
}
}
}
},
"dependencies": {
"next": "~16.0.1",
"react": "^19.0.0",

View File

@@ -1,12 +1,7 @@
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) }));
}
import { getBrands, getProducts, getCategories } from '@/lib/api';
export async function generateMetadata({
params,
@@ -14,6 +9,7 @@ export async function generateMetadata({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const brands = await getBrands();
const brand = brands.find((b) => b.id === Number(id));
return {
title: brand
@@ -30,6 +26,11 @@ export default async function BrandCatalogPage({
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { id } = await params;
const [brands, products, categories] = await Promise.all([
getBrands(),
getProducts(),
getCategories(),
]);
const brand = brands.find((b) => b.id === Number(id));
if (!brand) notFound();
@@ -53,6 +54,9 @@ export default async function BrandCatalogPage({
breadcrumbItems={breadcrumbItems}
basePath={basePath}
hiddenFilters={['brand']}
brands={brands}
categories={categories}
allProducts={brandProducts}
/>
);
}

View File

@@ -1,21 +1,17 @@
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 { Button } from '@/components/ui/Button/Button';
import { getBrands, getProducts } from '@/lib/api';
import styles from './page.module.scss';
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 brands = await getBrands();
const brand = brands.find((b) => b.id === Number(id));
return {
title: brand ? `${brand.name} | PAN-PROM` : 'Бренд | PAN-PROM',
@@ -28,6 +24,7 @@ export default async function BrandPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [brands, products] = await Promise.all([getBrands(), getProducts()]);
const brand = brands.find((b) => b.id === Number(id));
if (!brand) notFound();

View File

@@ -1,7 +1,7 @@
import { products } from '@/data/products';
import { categoryNameMap, CategorySlug } from '@/types';
import { filterProducts, parseSearchParams } from '@/lib/filterProducts';
import { CatalogView } from '@/components/CatalogView/CatalogView';
import { getProducts, getBrands, getCategories } from '@/lib/api';
export async function generateMetadata({
searchParams,
@@ -27,6 +27,11 @@ export default async function CatalogPage({
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const [products, brands, categories] = await Promise.all([
getProducts(),
getBrands(),
getCategories(),
]);
const filters = parseSearchParams(params);
const filtered = filterProducts(products, filters);
@@ -36,6 +41,12 @@ export default async function CatalogPage({
];
return (
<CatalogView products={filtered} breadcrumbItems={breadcrumbItems} />
<CatalogView
products={filtered}
breadcrumbItems={breadcrumbItems}
brands={brands}
categories={categories}
allProducts={products}
/>
);
}

View File

@@ -1,19 +1,16 @@
import { notFound } from 'next/navigation';
import { products } from '@/data/products';
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
import { ProductDetail } from '@/components/ProductDetail/ProductDetail';
import { getProducts } from '@/lib/api';
import styles from './page.module.scss';
export function generateStaticParams() {
return products.map((p) => ({ sku: encodeURIComponent(p.sku) }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ sku: string }>;
}) {
const { sku } = await params;
const products = await getProducts();
const product = products.find((p) => p.sku === decodeURIComponent(sku));
return {
title: product ? `${product.name} | PAN-PROM` : 'Продукт | PAN-PROM',
@@ -26,6 +23,7 @@ export default async function ProductPage({
params: Promise<{ sku: string }>;
}) {
const { sku } = await params;
const products = await getProducts();
const product = products.find((p) => p.sku === decodeURIComponent(sku));
if (!product) notFound();

View File

@@ -1,9 +1,10 @@
import Link from 'next/link';
import { Icon } from '@/components/Icon/Icon';
import { brands } from '@/data/brands';
import { getBrands } from '@/lib/api';
import styles from './BrandLogos.module.scss';
export function BrandLogos() {
export async function BrandLogos() {
const brands = await getBrands();
return (
<section className={styles.section}>
<div className={styles.header}>

View File

@@ -1,9 +1,7 @@
'use client';
import { Brand, Category, Product } from '@/types';
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';
@@ -11,11 +9,17 @@ import styles from './CatalogSidebar.module.scss';
interface CatalogSidebarProps {
basePath?: string;
hiddenFilters?: string[];
brands: Brand[];
categories: Category[];
products: Product[];
}
export function CatalogSidebar({
basePath = '/catalog',
hiddenFilters = [],
brands,
categories,
products,
}: CatalogSidebarProps) {
const { filters, setFilter } = useCatalogFilters(basePath);

View File

@@ -1,5 +1,5 @@
import { Suspense } from 'react';
import { Product } from '@/types';
import { Product, Brand, Category } from '@/types';
import { Breadcrumb } from '@/components/Breadcrumb/Breadcrumb';
import { CatalogSidebar } from '@/components/CatalogSidebar/CatalogSidebar';
import { ProductCard } from '@/components/ProductCard/ProductCard';
@@ -15,6 +15,9 @@ interface CatalogViewProps {
breadcrumbItems: BreadcrumbItem[];
basePath?: string;
hiddenFilters?: string[];
brands: Brand[];
categories: Category[];
allProducts?: Product[];
}
export function CatalogView({
@@ -22,11 +25,20 @@ export function CatalogView({
breadcrumbItems,
basePath = '/catalog',
hiddenFilters,
brands,
categories,
allProducts,
}: CatalogViewProps) {
return (
<div className={styles.layout}>
<Suspense fallback={null}>
<CatalogSidebar basePath={basePath} hiddenFilters={hiddenFilters} />
<CatalogSidebar
basePath={basePath}
hiddenFilters={hiddenFilters}
brands={brands}
categories={categories}
products={allProducts ?? products}
/>
</Suspense>
<div className={styles.main}>
<div className={styles.header}>

View File

@@ -1,8 +1,9 @@
import Link from 'next/link';
import { categories } from '@/data/categories';
import { getCategories } from '@/lib/api';
import styles from './Directions.module.scss';
export function Directions() {
export async function Directions() {
const categories = await getCategories();
return (
<section className={styles.section}>
<h2 className={styles.title}>Направления</h2>

View File

@@ -1,22 +1,32 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Icon } from '@/components/Icon/Icon';
import { useCart } from '@/components/CartProvider/CartProvider';
import { headerNav } from '@/data/navigation';
import { products } from '@/data/products';
import { Product } from '@/types';
import styles from './Header.module.scss';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
export function Header() {
const pathname = usePathname();
const { cart } = useCart();
const [searchQuery, setSearchQuery] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const [mobileMenu, setMobileMenu] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const searchRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch(`${API_URL}/products`)
.then((res) => res.json())
.then(setProducts)
.catch(() => {});
}, []);
const suggestions =
searchQuery.length > 1
? products

View File

@@ -0,0 +1,147 @@
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-slate-100) 25%,
var(--color-slate-200) 50%,
var(--color-slate-100) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 8px;
}
.directionsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
padding: var(--space-2xl) var(--space-xl);
@media (max-width: 768px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
}
.directionsCard {
height: 160px;
}
.brandsGrid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-md);
padding: var(--space-2xl) var(--space-xl);
@media (max-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
}
}
.brandsCard {
height: 100px;
}
.catalogGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-md);
padding: var(--space-md) 0;
}
.catalogCard {
height: 180px;
}
.catalogLayout {
display: flex;
gap: var(--space-xl);
max-width: 1280px;
margin: 0 auto;
padding: var(--space-xl);
}
.catalogSidebar {
width: 240px;
height: 400px;
flex-shrink: 0;
@media (max-width: 768px) {
display: none;
}
}
.catalogMain {
flex: 1;
}
.titleBar {
height: 24px;
width: 200px;
margin-bottom: var(--space-lg);
}
.pageContainer {
max-width: 1280px;
margin: 0 auto;
padding: var(--space-xl);
}
.brandHeader {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
.brandLogo {
width: 64px;
height: 64px;
border-radius: 12px;
}
.brandTitle {
height: 28px;
width: 200px;
margin-bottom: var(--space-sm);
}
.brandMeta {
height: 16px;
width: 120px;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-xl);
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.statCard {
height: 80px;
}
.productDetail {
height: 400px;
margin-top: var(--space-lg);
}

View File

@@ -0,0 +1,74 @@
import styles from './Skeleton.module.scss';
function Box({ className }: { className?: string }) {
return <div className={`${styles.skeleton} ${className ?? ''}`} />;
}
export function DirectionsSkeleton() {
return (
<section>
<div className={styles.directionsGrid}>
{Array.from({ length: 4 }).map((_, i) => (
<Box key={i} className={styles.directionsCard} />
))}
</div>
</section>
);
}
export function BrandLogosSkeleton() {
return (
<section>
<div className={styles.brandsGrid}>
{Array.from({ length: 12 }).map((_, i) => (
<Box key={i} className={styles.brandsCard} />
))}
</div>
</section>
);
}
export function CatalogSkeleton() {
return (
<div className={styles.catalogLayout}>
<Box className={styles.catalogSidebar} />
<div className={styles.catalogMain}>
<Box className={styles.titleBar} />
<div className={styles.catalogGrid}>
{Array.from({ length: 6 }).map((_, i) => (
<Box key={i} className={styles.catalogCard} />
))}
</div>
</div>
</div>
);
}
export function BrandPageSkeleton() {
return (
<div className={styles.pageContainer}>
<Box className={styles.titleBar} />
<div className={styles.brandHeader}>
<Box className={styles.brandLogo} />
<div>
<Box className={styles.brandTitle} />
<Box className={styles.brandMeta} />
</div>
</div>
<div className={styles.cardGrid}>
{Array.from({ length: 4 }).map((_, i) => (
<Box key={i} className={styles.statCard} />
))}
</div>
</div>
);
}
export function ProductPageSkeleton() {
return (
<div className={styles.pageContainer}>
<Box className={styles.titleBar} />
<Box className={styles.productDetail} />
</div>
);
}

13
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Brand, Product, Category } from '@/types';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
async function fetchApi<T>(path: string): Promise<T> {
const res = await fetch(`${API_URL}${path}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export const getProducts = () => fetchApi<Product[]>('/products');
export const getBrands = () => fetchApi<Brand[]>('/brands');
export const getCategories = () => fetchApi<Category[]>('/categories');

View File

@@ -1,7 +0,0 @@
export interface Brand {
id: number;
name: string;
country: string;
logo: string;
category: string;
}

View File

@@ -1,24 +0,0 @@
export type CategorySlug = 'gidravlika' | 'pnevmatika' | 'asu' | 'zip';
export interface Category {
slug: CategorySlug;
name: string;
icon: string;
description: string;
brands: string;
color: string;
}
export const categorySlugMap: Record<string, CategorySlug> = {
'Гидравлика': 'gidravlika',
'Пневматика': 'pnevmatika',
'АСУ': 'asu',
'ЗИП': 'zip',
};
export const categoryNameMap: Record<CategorySlug, string> = {
gidravlika: 'Гидравлика',
pnevmatika: 'Пневматика',
asu: 'АСУ',
zip: 'ЗИП',
};

View File

@@ -1,4 +1,2 @@
export type { Brand } from './brand';
export type { Product } from './product';
export type { Category, CategorySlug } from './category';
export { categorySlugMap, categoryNameMap } from './category';
export type { Brand, Product, Category, CategorySlug } from '@spairtech/shared';
export { categorySlugMap, categoryNameMap } from '@spairtech/shared';

View File

@@ -1,11 +0,0 @@
export interface Product {
id: number;
sku: string;
name: string;
brand: string;
category: string;
subcategory: string;
price: string;
image: string;
inStock: boolean;
}

View File

@@ -47,5 +47,10 @@
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
],
"references": [
{
"path": "../../libs/shared"
}
]
}

View File

@@ -1,3 +1,44 @@
export function shared(): string {
return 'shared';
export interface Brand {
id: number;
name: string;
country: string;
logo: string;
category: string;
}
export interface Product {
id: number;
sku: string;
name: string;
brand: string;
category: string;
subcategory: string;
price: string;
image: string;
inStock: boolean;
}
export type CategorySlug = 'gidravlika' | 'pnevmatika' | 'asu' | 'zip';
export interface Category {
slug: CategorySlug;
name: string;
icon: string;
description: string;
brands: string;
color: string;
}
export const categorySlugMap: Record<string, CategorySlug> = {
'Гидравлика': 'gidravlika',
'Пневматика': 'pnevmatika',
'АСУ': 'asu',
'ЗИП': 'zip',
};
export const categoryNameMap: Record<CategorySlug, string> = {
gidravlika: 'Гидравлика',
pnevmatika: 'Пневматика',
asu: 'АСУ',
zip: 'ЗИП',
};