diff --git a/front/src/api/client.ts b/front/src/api/client.ts new file mode 100644 index 0000000..4188ab4 --- /dev/null +++ b/front/src/api/client.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +export const API_BASE = g;/* process.env.REACT_APP_API_BASE || "";*/ +const client = axios.create({ + baseURL: API_BASE, + headers: { "Content-Type": "application/json" }, +}); + +export default client; \ No newline at end of file diff --git a/front/src/app/store.ts b/front/src/app/store.ts index 11ebdd1..8bd0c79 100644 --- a/front/src/app/store.ts +++ b/front/src/app/store.ts @@ -1,14 +1,17 @@ import { configureStore } from "@reduxjs/toolkit"; -import catalogReducer from "../features/catalog/catalogSlice"; +import catalogReducer from "../features/catalog/productsSlice"; +import categoriesReducer from "../features/catalog/categoriesSlice" +import productsReducer from "../features/catalog/productsSlice" export const store = configureStore({ reducer: { - catalog: catalogReducer, + categories: categoriesReducer, + products: productsReducer, }, - middleware: (getDefaultMiddleware) => + /*middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, // отключаем для async thunk - }), + }),*/ }); export type RootState = ReturnType; diff --git a/front/src/features/catalog/CategoriesGrid.tsx b/front/src/features/catalog/CategoriesGrid.tsx new file mode 100644 index 0000000..7eab034 --- /dev/null +++ b/front/src/features/catalog/CategoriesGrid.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import styles from "./CategoryCards.module.css"; +import { useAppSelector } from "../../app/hooks"; + +interface Props { onSelect?: (id:number) => void } +const CategoryCards: React.FC = ({ onSelect }) => { + const categories = useAppSelector(s => s.categories.items); + + return ( +
+ {categories.map(c => ( +
onSelect?.(c.id)}> + {c.image ? {c.name} : {c.name}} +
{c.name}
+
+ ))} +
+ ); +}; +export default CategoryCards; \ No newline at end of file diff --git a/front/src/features/catalog/CategoryCards.tsx b/front/src/features/catalog/CategoryCards.tsx new file mode 100644 index 0000000..8f07c5d --- /dev/null +++ b/front/src/features/catalog/CategoryCards.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import styles from "./CategoryCards.module.css"; +import { useAppSelector } from "../../app/hooks"; + +interface Props { + onSelect?: (id:number) => void +} + +const CategoryCards: React.FC = ({ onSelect }) => { + const categories = useAppSelector(s => s.categories.items); + + return ( +
+ {categories.map(c => ( +
onSelect?.(c.id)}> + {c.image ? {c.name} : {c.name}} +
{c.name}
+
+ ))} +
+ ); +}; +export default CategoryCards; \ No newline at end of file diff --git a/front/src/features/catalog/CategoryFilters.tsx b/front/src/features/catalog/CategoryFilters.tsx index 98cbbf2..a086911 100644 --- a/front/src/features/catalog/CategoryFilters.tsx +++ b/front/src/features/catalog/CategoryFilters.tsx @@ -1,22 +1,58 @@ import { useAppSelector, useAppDispatch } from "../../app/hooks"; -import { setSelectedCategory } from "../store/categoriesSlice"; +import { setSelectedCategory } from "./categoriesSlice"; import styles from "./CategoriesSidebar.module.css"; +import { setFilters, fetchProductsByCategory, setPage } from "./productsSlice"; +import { useState } from "react"; -export const CategoriesSidebar = () => { - const { items, selectedCategoryId } = useAppSelector(s => s.categories); +export const CategoryFilters = () => { const dispatch = useAppDispatch(); + const { items: cats, selectedCategoryId } = useAppSelector(s => s.categories); + const products = useAppSelector(s => s.products); + const cat = cats.find(x => x.id === selectedCategoryId); + const [local, setLocal] = useState>({}); + const apply = () => { + dispatch(setFilters(local)); + dispatch(setPage(1)); + if (selectedCategoryId) dispatch(fetchProductsByCategory({ categoryId: selectedCategoryId, page: 1, limit: products.limit, filters: local })); + }; + + if (!cat) return null; return ( -
- {items.map(cat => ( -
dispatch(setSelectedCategory(cat.id))} - > - {cat.name} -
- ))} +
+

ПАРАМЕТРЫ

+
+ {cat.atributes.map(attr => ( +
+
{`${attr.name}:`}
+
+ {attr.vals.map(v => { + const key = `attr_${attr.id}`; + const active = local[key]?.includes(v.val); + return ( + + ); + })} +
+
+ ))} +
+ +
+ + +
); -}; \ No newline at end of file +}; + +export default CategoryFilters; \ No newline at end of file diff --git a/front/src/features/catalog/Pagination.tsx b/front/src/features/catalog/Pagination.tsx new file mode 100644 index 0000000..939d2a9 --- /dev/null +++ b/front/src/features/catalog/Pagination.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import styles from "./Pagination.module.css"; +import { useAppSelector, useAppDispatch } from "../../app/hooks"; +import { setPage, fetchProductsByCategory } from "./productsSlice"; + +interface Props { categoryId: number } + +const Pagination: React.FC = ({ categoryId }) => { + const dispatch = useAppDispatch(); + const { total, page, limit } = useAppSelector(s => s.products); + const totalPages = Math.ceil(total / limit); + if (totalPages <= 1) return null; + + const go = (p:number) => { + dispatch(setPage(p)); + dispatch(fetchProductsByCategory({ categoryId, page: p, limit })); + window.scrollTo({ top: 200, behavior: "smooth" }); + } + + return ( +
+ + +
+ {Array.from({length: totalPages}, (_,i) => i+1).map(p => ( + + ))} +
+ + +
+ ); +}; +export default Pagination; \ No newline at end of file diff --git a/front/src/features/catalog/ProductGrid.tsx b/front/src/features/catalog/ProductGrid.tsx index 0ddbd2c..f02ad5b 100644 --- a/front/src/features/catalog/ProductGrid.tsx +++ b/front/src/features/catalog/ProductGrid.tsx @@ -1,13 +1,12 @@ import React from "react"; -import type { Product } from "../../types/product"; import ProductCard from "./ProductCard"; import styles from "./Catalog.module.css"; +import { useAppSelector } from "../../app/hooks"; -interface Props { - products: Product[]; -} -const ProductGrid: React.FC = ({ products }) => { +const ProductGrid: React.FC = () => { + const {items: products} = useAppSelector(p => p.products) + return (
{products.map((p) => ( diff --git a/front/src/features/catalog/SidebarCategories.tsx b/front/src/features/catalog/SidebarCategories.tsx new file mode 100644 index 0000000..b573e26 --- /dev/null +++ b/front/src/features/catalog/SidebarCategories.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; +import styles from "./SidebarCategories.module.css"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { fetchCategories, setSelectedCategory } from "./categoriesSlice"; +import { fetchProductsByCategory, setFilters, setPage } from "./productsSlice"; + +const SidebarCategories: React.FC = () => { + const dispatch = useAppDispatch(); + const { items, selectedCategoryId, loading } = useAppSelector(s => s.categories); + + useEffect(() => { + dispatch(fetchCategories()); + }, [dispatch]); + + const onClick = (catId: number) => { + dispatch(setSelectedCategory(catId)); + dispatch(setFilters({})); // сброс фильтров + dispatch(setPage(1)); + dispatch(fetchProductsByCategory({ categoryId: catId, page: 1, limit: 9 })); + }; + return( + + ) +} + +export default SidebarCategories; \ No newline at end of file diff --git a/front/src/features/catalog/catalogSlice.ts b/front/src/features/catalog/catalogSlice.ts deleted file mode 100644 index 5e0c8e2..0000000 --- a/front/src/features/catalog/catalogSlice.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; -import type {PayloadAction} from "@reduxjs/toolkit"; -import { fetchCategoryProducts, fetchCategories } from "../../api/catalogApi"; -import type { Product } from "../../types/product"; - -interface CatalogState { - products: Product[]; - filtered: Product[]; - loading: boolean; - error: string | null; - filters: Record; - currentCategory: string | null; -} - -const initialState: CatalogState = { - products: [], - filtered: [], - loading: false, - error: null, - filters: {}, - currentCategory: null, -}; - -export const getCategories = createAsyncThunk( - "catalog/getCategories", - async (categoryid: string) => { - const data = await fetchCategories(categoryid); - return data; - } -); - -export const getCategoryProducts = createAsyncThunk( - "catalog/getCategoryProducts", - async (category: string) => { - const data = await fetchCategoryProducts(category); - return data; - } -); - -const catalogSlice = createSlice({ - name: "catalog", - initialState, - reducers: { - setFilter(state, action: PayloadAction<{ name: string; value: string }>) { - const { name, value } = action.payload; - const active = state.filters[name] || []; - state.filters[name] = active.includes(value) - ? active.filter((v) => v !== value) - : [...active, value]; - }, - applyFilters(state) { - state.filtered = state.products.filter((p) => - Object.entries(state.filters).every(([key, values]) => - values.length ? values.includes(p[key as keyof Product] as string) : true - ) - ); - }, - }, - extraReducers: (builder) => { - builder - .addCase(getCategoryProducts.pending, (state) => { - state.loading = true; - state.error = null; - }) - .addCase(getCategoryProducts.fulfilled, (state, action) => { - state.loading = false; - state.products = action.payload; - state.filtered = action.payload; - }) - .addCase(getCategoryProducts.rejected, (state, action) => { - state.loading = false; - state.error = action.error.message || "Ошибка загрузки данных"; - }); - }, -}); - -export const { setFilter, applyFilters } = catalogSlice.actions; -export default catalogSlice.reducer; \ No newline at end of file diff --git a/front/src/features/catalog/catalogeSidebar.tsx b/front/src/features/catalog/catalogeSidebar.tsx deleted file mode 100644 index 4ce1209..0000000 --- a/front/src/features/catalog/catalogeSidebar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react"; -import { useDispatch } from "react-redux"; -import { setFilter, applyFilters } from "./catalogSlice"; -import styles from "./Catalog.module.css"; -import type { Category } from "../../types/categories"; -import type { CategoryAtributes } from "../../types/categories"; -import type { CategoryAtrVals } from "../../types/categories"; - -interface Props{ - categories: Category[]; - atributes: CategoryAtributes[] - atrVals: CategoryAtrVals[]; -} - -const CatalogSidebar: React.FC = ({categories, atributes, atrVals}) => { - const dispatch = useDispatch(); - - const handleFilterChange = (name: string, value: string) => { - dispatch(setFilter({ name, value })); - dispatch(applyFilters()); - }; - - return ( - - ); -}; - -export default CatalogSidebar; \ No newline at end of file diff --git a/front/src/features/catalog/categoriesSlice.ts b/front/src/features/catalog/categoriesSlice.ts index ecd87d1..4c38a87 100644 --- a/front/src/features/catalog/categoriesSlice.ts +++ b/front/src/features/catalog/categoriesSlice.ts @@ -1,11 +1,12 @@ import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; import type { Category } from "../../types/categories"; +import axios from "axios"; export const fetchCategories = createAsyncThunk( - "categories/fetch", + "categories/fetchCategories", async () => { - const res = await fetch("/api/categories"); - return await res.json(); + const res = await axios.get("/api/categories"); + return res.data; } ); diff --git a/front/src/features/catalog/productsSlice.ts b/front/src/features/catalog/productsSlice.ts new file mode 100644 index 0000000..17c4591 --- /dev/null +++ b/front/src/features/catalog/productsSlice.ts @@ -0,0 +1,95 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import type {PayloadAction} from "@reduxjs/toolkit"; +import client from "../../api/client"; +import type { Product } from "../../types/product"; +import axios from "axios"; + +interface FetchParams { + categoryId: number; + page?: number; + limit?: number; + filters?: Record; +} + +export const fetchProductsByCategory = createAsyncThunk( + "products/fetchByCategory", + async (params: FetchParams) => { + const { categoryId, page = 1, limit = 9, filters} = params; + const qp = new URLSearchParams(); + qp.append("categoryId", String(categoryId)); + qp.append("page", String(page)); + qp.append("limit", String(limit)); + if (filters) { + Object.keys(filters).forEach((k) => { + const v = filters[k]; + if (Array.isArray(v)) qp.append(k, v.join(",")); + else qp.append(k, String(v)); + }); + } + const res = await client.get(`/products?${qp.toString()}`); + // ожидаем { items: Product[], total: number, page: number, limit: number } + return res.data; + } +); + +interface ProductsState { + items: Product[]; + total: number; + page: number; + limit: number; + loading: boolean; + error: string | null; + filtersApplied: Record; +} + +const initialState: ProductsState = { + items: [], + total: 0, + page: 1, + limit: 9, + loading: false, + error: null, + filtersApplied: {}, +}; + +const slice = createSlice({ + name: "products", + initialState, + reducers: { + setPage(state, action: PayloadAction) { + state.page = action.payload; + }, + setLimit(state, action: PayloadAction) { + state.limit = action.payload; + }, + setFilters(state, action: PayloadAction) { + state.filtersApplied = action.payload; + }, + resetProducts(state) { + state.items = []; + state.total = 0; + state.page = 1; + } + }, + extraReducers: (builder) => { + builder + .addCase(fetchProductsByCategory.pending, (s) => { + s.loading = true; + s.error = null; + }) + .addCase(fetchProductsByCategory.fulfilled, (s, a) => { + s.loading = false; + s.items = a.payload.items; + s.total = a.payload.total; + s.page = a.payload.page ?? s.page; + s.limit = a.payload.limit ?? s.limit; + }) + .addCase(fetchProductsByCategory.rejected, (s, a) => { + s.loading = false; + s.error = a.error.message ?? "Ошибка"; + }); + } +}); + +export const { setPage, setLimit, setFilters, resetProducts } = slice.actions; +export default slice.reducer; \ No newline at end of file diff --git a/front/src/pages/Products/Products.tsx b/front/src/pages/Products/Products.tsx index e85c1ad..a4bf779 100644 --- a/front/src/pages/Products/Products.tsx +++ b/front/src/pages/Products/Products.tsx @@ -1,31 +1,36 @@ -import React, { useEffect } from "react"; -import { useParams } from "react-router-dom"; -import type { RootState } from "../../app/store"; -import { getCategoryProducts } from "../../features/catalog/catalogSlice"; -import CatalogSidebar from "../../features/catalog/catalogeSidebar" +import React from "react"; +import styles from "./ProductsPage.module.css"; +import SidebarCategories from "../../features/catalog/SidebarCategories"; +import CategoryCards from "../../features/catalog/CategoryCards"; +import Filters from "../../features/catalog/CategoryFilters"; import ProductGrid from "../../features/catalog/ProductGrid"; -import styles from "../../features/catalog/catalog.module.css"; -import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import Pagination from "../../features/catalog/Pagination"; +import { useAppSelector } from "../../app/hooks"; -const CatalogPage: React.FC = () => { - const { category } = useParams<{ category: string }>(); - const dispatch = useAppDispatch() - const { loading, filtered, error } = useAppSelector((s: RootState) => s.catalog); - - useEffect(() => { - if (category) dispatch(getCategoryProducts(category)); - }, [category]); +const ProductsPage: React.FC = () => { + const { selectedCategoryId } = useAppSelector(s => s.categories); return ( -
- -
- {loading &&

Загрузка...

} - {error &&

Ошибка: {error}

} - +
+ +
+ {!selectedCategoryId ? ( + {}} /> + ) : ( + <> +
+ +
+ +
+ + +
+ + )}
); }; -export default CatalogPage; \ No newline at end of file +export default ProductsPage; \ No newline at end of file diff --git a/front/src/types/categories.ts b/front/src/types/categories.ts index e0c1813..3e813a7 100644 --- a/front/src/types/categories.ts +++ b/front/src/types/categories.ts @@ -12,5 +12,7 @@ export interface CategoryAtributes { export interface Category { id: number; name: string; + image?: string; atributes: CategoryAtributes[]; -} \ No newline at end of file +} + diff --git a/front/src/types/product.ts b/front/src/types/product.ts index 801ce97..eaf7d03 100644 --- a/front/src/types/product.ts +++ b/front/src/types/product.ts @@ -3,6 +3,4 @@ export interface Product { name: string; image: string; description: string; - size: string; - category: string; } \ No newline at end of file