added filter to category page

This commit is contained in:
Jelaletdin12
2025-12-20 03:34:46 +05:00
parent 73cd90207c
commit 903d6e1f4f
13 changed files with 1041 additions and 784 deletions

View File

@@ -11,7 +11,6 @@ export const brandsApi = baseApi.injectEndpoints({
if (params.page) {
queryParams.append("page", params.page);
}
if (params.limit) {
queryParams.append("limit", params.limit);
}
@@ -29,12 +28,10 @@ export const brandsApi = baseApi.injectEndpoints({
getBrandProducts: builder.query({
query: (params) => {
// Handle both string ID and object with pagination params
if (typeof params === 'string' || typeof params === 'number') {
return `/brands/${params}/products`;
}
// Handle object with pagination
const { id, page = 1, limit } = params;
let url = `/brands/${id}/products?page=${page}`;
@@ -44,9 +41,10 @@ export const brandsApi = baseApi.injectEndpoints({
return url;
},
transformResponse: (response) => {
return response.data || response;
},
transformResponse: (response) => ({
data: response.data || response,
pagination: response.pagination || {},
}),
}),
}),
});

View File

@@ -5,17 +5,24 @@ export const categoriesApi = baseApi.injectEndpoints({
getCategories: builder.query({
query: (type = "tree") => `/categories?type=${type}`,
}),
getCategoryProducts: builder.query({
query: ({ categoryId, page = 1, limit }) => {
let url = `categories/${categoryId}/products?page=${page}`;
if (limit) url += `&limit=${limit}`;
return url;
query: ({ categoryId, page = 1, limit, brands, min_price, max_price }) => {
const params = new URLSearchParams();
params.append('page', page);
if (limit) params.append('limit', limit);
if (brands) params.append('brands', brands);
if (min_price) params.append('min_price', min_price);
if (max_price) params.append('max_price', max_price);
return `categories/${categoryId}/products?${params.toString()}`;
},
transformResponse: (response) => ({
data: response.data || [],
pagination: response.pagination || {},
}),
}),
getAllCategoryProducts: builder.query({
async queryFn(category, queryApi, extraOptions, baseQuery) {
const fetchProducts = async (categoryId) => {
@@ -36,7 +43,7 @@ export const categoriesApi = baseApi.injectEndpoints({
getAllCategoryProductsPaginated: builder.query({
async queryFn(
{ category, page = 1, limit = 6 },
{ category, page = 1, limit = 6, brands, min_price, max_price },
queryApi,
extraOptions,
baseQuery
@@ -51,14 +58,20 @@ export const categoriesApi = baseApi.injectEndpoints({
const perCategoryLimit = Math.ceil(limit / categoryIds.length);
for (const categoryId of categoryIds) {
const params = new URLSearchParams();
params.append('page', currentPage);
params.append('limit', perCategoryLimit);
if (brands) params.append('brands', brands);
if (min_price) params.append('min_price', min_price);
if (max_price) params.append('max_price', max_price);
const result = await baseQuery(
`categories/${categoryId}/products?page=${currentPage}&limit=${perCategoryLimit}`
`categories/${categoryId}/products?${params.toString()}`
);
if (result.data && result.data.data) {
allPageProducts = [...allPageProducts, ...result.data.data];
hasMoreByCategory[categoryId] =
!!result.data.pagination.next_page_url;
hasMoreByCategory[categoryId] = !!result.data.pagination.next_page_url;
}
}
@@ -90,9 +103,11 @@ export const categoriesApi = baseApi.injectEndpoints({
}
},
}),
getProductById: builder.query({
query: (productId) => `/products/${productId}`,
}),
getRelatedProducts: builder.query({
query: (productId) => `/products/${productId}/related`,
}),
@@ -107,4 +122,4 @@ export const {
useLazyGetAllCategoryProductsPaginatedQuery,
useGetProductByIdQuery,
useGetRelatedProductsQuery,
} = categoriesApi;
} = categoriesApi;

View File

@@ -5,12 +5,13 @@ export const collectionsApi = baseApi.injectEndpoints({
getCollections: builder.query({
query: () => `/collections`,
}),
getCollectionById: builder.query({
query: (collectionId) => `/collections/${collectionId}`,
}),
getCollectionProducts: builder.query({
query: (collectionId) => `/collections/${collectionId}/products`,
// Ürünleri dönüştürerek boş kontrol edilebilir
transformResponse: (response) => {
return {
data: response.data || [],
@@ -18,7 +19,7 @@ export const collectionsApi = baseApi.injectEndpoints({
};
},
}),
// Yeni endpoint: Koleksiyonun ürün içerip içermediğini kontrol eder
checkCollectionHasProducts: builder.query({
query: (collectionId) => `/collections/${collectionId}/products?limit=1`,
transformResponse: (response) => {
@@ -27,10 +28,18 @@ export const collectionsApi = baseApi.injectEndpoints({
};
},
}),
// Sayfalı koleksiyon ürünleri için endpoint
getCollectionProductsPaginated: builder.query({
query: ({ collectionId, page = 1, limit = 6 }) =>
`/collections/${collectionId}/products?page=${page}${limit ? `&limit=${limit}` : ''}`,
query: ({ collectionId, page = 1, limit = 6, brands, min_price, max_price }) => {
const params = new URLSearchParams();
params.append('page', page);
if (limit) params.append('limit', limit);
if (brands) params.append('brands', brands);
if (min_price) params.append('min_price', min_price);
if (max_price) params.append('max_price', max_price);
return `/collections/${collectionId}/products?${params.toString()}`;
},
transformResponse: (response) => ({
data: response.data || [],
pagination: response.pagination || {},

77
src/app/api/filtersApi.js Normal file
View File

@@ -0,0 +1,77 @@
import { baseApi } from "./baseApi"
export const filtersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getFilters: builder.query({
query: (params) => {
const queryParams = new URLSearchParams()
if (params?.category_id) {
queryParams.append("category_id", String(params.category_id))
}
if (params?.collection_id) {
queryParams.append("collection_id", String(params.collection_id))
}
if (params?.brand_id) {
queryParams.append("brand_id", String(params.brand_id))
}
return `/filters?${queryParams.toString()}`
},
transformResponse: (response) => {
return {
categories: response.data?.categories || [],
brands: response.data?.brands || [],
}
},
keepUnusedDataFor: 300,
serializeQueryArgs: ({ queryArgs }) => {
if (!queryArgs) return 'no-params';
const parts = [];
if (queryArgs.category_id) {
parts.push(`cat:${queryArgs.category_id}`);
}
if (queryArgs.collection_id) {
parts.push(`col:${queryArgs.collection_id}`);
}
if (queryArgs.brand_id) {
parts.push(`brd:${queryArgs.brand_id}`);
}
return parts.length > 0 ? parts.join('|') : 'no-params';
},
merge: (currentCache, newItems) => {
return newItems;
},
refetchOnMountOrArgChange: 30, // Refetch if older than 30 seconds
refetchOnFocus: false,
refetchOnReconnect: false,
providesTags: (result, error, arg) => {
if (!arg) return [{ type: "Filters", id: "no-params" }];
const tags = [{ type: "Filters", id: "LIST" }];
if (arg.category_id) {
tags.push({ type: "Filters", id: `cat-${arg.category_id}` });
}
if (arg.collection_id) {
tags.push({ type: "Filters", id: `col-${arg.collection_id}` });
}
if (arg.brand_id) {
tags.push({ type: "Filters", id: `brd-${arg.brand_id}` });
}
return tags;
},
}),
}),
overrideExisting: true,
})
export const { useGetFiltersQuery, useLazyGetFiltersQuery } = filtersApi

View File

@@ -13,6 +13,7 @@ import { mediaApi } from "./api/bannersApi";
import { reviewsApi } from "./api/reviewApi";
import { profileApi } from "./api/myProfileApi";
import { contactApi } from "./api/contactUs";
import { filtersApi } from "./api/filtersApi";
const store = configureStore({
reducer: {
@@ -30,6 +31,7 @@ const store = configureStore({
[reviewsApi.reducerPath]: reviewsApi.reducer,
[profileApi.reducerPath]: profileApi.reducer,
[contactApi.reducerPath]: contactApi.reducer,
[filtersApi.reducerPath]: filtersApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
@@ -46,6 +48,7 @@ const store = configureStore({
mediaApi.middleware,
profileApi.middleware,
contactApi.middleware,
filtersApi.middleware
),
});

View File

@@ -1,38 +1,18 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { Drawer, Input, Checkbox } from "antd";
import styles from "./BrandsSidebar.module.scss";
import { useTranslation } from "react-i18next";
import brand from "../../assets/icons/brand.svg";
const Sidebar = () => {
const BrandSidebar = ({
brands = [],
selectedBrand = null,
onBrandSelect,
onBrandDeselect
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const { t, i18n } = useTranslation();
const brands = [
"Abat",
"Altın",
"Arçalyk",
"Aýaz baba",
"Balşeker",
"Bars",
"Belet Film",
"Beýlekiler / Другие",
"Bingo",
"Bold",
"Carte Noire",
"Çaykur",
"Dabara",
"Datmeni",
"Elin",
"Emin Et",
"Enemeli",
"Ermak",
"Eyfel",
"Familia",
"Farmasi",
"Ferrero Rocher",
"Granum",
];
const { t } = useTranslation();
const handleToggle = () => {
setIsOpen(!isOpen);
@@ -42,15 +22,28 @@ const Sidebar = () => {
setSearchTerm(e.target.value.toLowerCase());
};
const filteredBrands = brands.filter((brand) =>
brand.toLowerCase().includes(searchTerm)
);
const filteredBrands = useMemo(() => {
if (!brands || brands.length === 0) return [];
return brands.filter((brand) =>
brand.name.toLowerCase().includes(searchTerm)
);
}, [brands, searchTerm]);
const handleBrandChange = (brandId, checked) => {
if (checked) {
onBrandSelect?.(brandId);
} else {
onBrandDeselect?.();
}
};
return (
<div className={styles.sidebarContainer}>
<button onClick={handleToggle} className={styles.mobileNavButton}>
<img src={brand} alt="" />
{t("navbar.brands")}
{selectedBrand && <span className={styles.badge}>1</span>}
</button>
<Drawer
@@ -67,16 +60,30 @@ const Sidebar = () => {
onChange={handleSearch}
className={styles.searchInput}
/>
<div className={styles.brandsList}>
{filteredBrands.map((brand, index) => (
<div key={index} className={styles.brandItem}>
<Checkbox>{brand}</Checkbox>
</div>
))}
</div>
{filteredBrands.length > 0 ? (
<div className={styles.brandsList}>
{filteredBrands.map((brand) => (
<div key={brand.id} className={styles.brandItem}>
<Checkbox
checked={selectedBrand === brand.id}
onChange={(e) => handleBrandChange(brand.id, e.target.checked)}
>
{brand.name}
</Checkbox>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
{brands.length === 0
? t("common.noData")
: t("common.noResults")}
</div>
)}
</Drawer>
</div>
);
};
export default Sidebar;
export default BrandSidebar;

View File

@@ -5,6 +5,7 @@
margin: auto;
padding: 20px 1.375rem;
box-sizing: border-box;
flex-direction: column;
h2 {
font-size: 24px;
font-weight: 700;

View File

@@ -0,0 +1,59 @@
import React, { useMemo } from "react";
import { Breadcrumb } from "antd";
import { useTranslation } from "react-i18next";
import styles from "../CategoryPage.module.scss";
const CategoryBreadcrumbs = ({ categoriesData, categoryId, onCategoryClick }) => {
const { t } = useTranslation();
const breadcrumbs = useMemo(() => {
if (!categoriesData?.data || !categoryId) return [];
const buildPath = (categories, targetId, path = []) => {
for (const category of categories) {
if (category.id === parseInt(targetId)) {
return [...path, category];
}
if (category.children) {
const found = buildPath(category.children, targetId, [...path, category]);
if (found) return found;
}
}
return null;
};
return buildPath(categoriesData.data, categoryId) || [];
}, [categoriesData, categoryId]);
if (breadcrumbs.length === 0) return null;
return (
<Breadcrumb className={styles.breadcrumb}>
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<Breadcrumb.Item key={crumb.id}>
{isLast ? (
<span>{crumb.name}</span>
) : (
<a
onClick={(e) => {
e.preventDefault();
onCategoryClick?.(crumb.id);
}}
style={{ cursor: 'pointer' }}
>
{crumb.name}
</a>
)}
</Breadcrumb.Item>
);
})}
</Breadcrumb>
);
};
export default CategoryBreadcrumbs;

View File

@@ -0,0 +1,98 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { TiTick } from "react-icons/ti";
import styles from "../CategoryPage.module.scss";
const CategoryFilters = ({
filtersData,
selectedFilterCategory,
selectedFilterBrand,
brandSearchQuery,
searchQuery,
onCategorySelect,
onCategoryDeselect,
onBrandSelect,
onBrandDeselect,
onBrandSearchChange,
}) => {
const { t } = useTranslation();
if (searchQuery) return null;
return (
<aside className={styles.sidebar}>
{filtersData?.categories?.length > 0 && (
<div className={styles.filterSection}>
<h3>{t("category.subCategories")}</h3>
<ul>
{filtersData.categories.map((category) => (
<li
key={category.id}
onClick={() => {
if (selectedFilterCategory === category.id) {
onCategoryDeselect();
} else {
onCategorySelect(category.id);
}
}}
className={
selectedFilterCategory === category.id
? styles.activeSubcategory
: ""
}
>
{category.name}
</li>
))}
</ul>
</div>
)}
{filtersData?.brands?.length > 0 && (
<div className={styles.filterSection}>
<h3>{t("navbar.brands")}</h3>
<input
type="text"
placeholder="Gözleg"
value={brandSearchQuery}
onChange={(e) => onBrandSearchChange(e.target.value)}
className={styles.searchInput}
/>
<ul>
{filtersData.brands
.filter((brand) =>
brand.name
.toLowerCase()
.includes(brandSearchQuery.toLowerCase())
)
.map((brand) => (
<li key={brand.id}>
<label>
<input
type="checkbox"
checked={selectedFilterBrand === brand.id}
onChange={() => {
if (selectedFilterBrand === brand.id) {
onBrandDeselect();
} else {
onBrandSelect(brand.id);
}
}}
/>
<span className={styles.customCheckbox}>
<TiTick className={styles.checkIcon} />
</span>
{brand.name}
</label>
</li>
))}
</ul>
</div>
)}
</aside>
);
};
export default CategoryFilters;

View File

@@ -0,0 +1,110 @@
import { useState, useEffect, useMemo } from "react";
import { useGetCategoriesQuery } from "../../../app/api/categories";
import { useGetCollectionByIdQuery } from "../../../app/api/collectionsApi";
import {
useGetFiltersQuery,
useLazyGetFiltersQuery,
} from "../../../app/api/filtersApi";
const useCategoryData = ({
categoryId,
collectionId,
brandId,
selectedFilterCategory,
searchQuery,
}) => {
const [selectedCategory, setSelectedCategory] = useState(null);
const { data: categoriesData } = useGetCategoriesQuery("tree");
const filterParams = useMemo(() => {
if (searchQuery) return null;
if (selectedFilterCategory) return { category_id: selectedFilterCategory };
if (categoryId) return { category_id: categoryId };
if (collectionId) return { collection_id: collectionId };
if (brandId) return { brand_id: brandId };
return null;
}, [categoryId, collectionId, brandId, selectedFilterCategory, searchQuery]);
const {
data: filtersData,
isLoading: filtersLoading,
error: filtersError,
} = useGetFiltersQuery(filterParams, {
skip: !filterParams,
});
const [fetchFilters, { data: lazyFiltersData }] = useLazyGetFiltersQuery();
const {
data: collectionData,
isLoading: collectionLoading,
error: collectionError,
} = useGetCollectionByIdQuery(collectionId, {
skip: !collectionId,
});
const isSubCategory = useMemo(() => {
if (!categoriesData?.data || !categoryId) return false;
const checkIsSubCategory = (categories, targetId) => {
for (const category of categories) {
if (category.children) {
for (const subCategory of category.children) {
if (subCategory.id === parseInt(targetId)) return true;
if (subCategory.children) {
const found = checkIsSubCategory([subCategory], targetId);
if (found) return true;
}
}
}
}
return false;
};
return checkIsSubCategory(categoriesData.data, parseInt(categoryId));
}, [categoriesData, categoryId]);
const activeFilters = useMemo(() => {
return selectedFilterCategory && lazyFiltersData
? lazyFiltersData
: filtersData;
}, [selectedFilterCategory, lazyFiltersData, filtersData]);
useEffect(() => {
if (!categoryId || !categoriesData?.data) {
setSelectedCategory(null);
return;
}
const findCategory = (categories, targetId) => {
for (const cat of categories) {
if (cat.id === parseInt(targetId)) return cat;
if (cat.children) {
const found = findCategory(cat.children, targetId);
if (found) return found;
}
}
return null;
};
const category = findCategory(categoriesData.data, parseInt(categoryId));
setSelectedCategory(category);
}, [categoryId, categoriesData]);
const isLoading = filtersLoading || collectionLoading;
const hasError = filtersError || collectionError;
return {
categoriesData,
selectedCategory,
isSubCategory,
filtersData: activeFilters,
collectionData,
isLoading,
hasError,
fetchFilters,
};
};
export default useCategoryData;

View File

@@ -0,0 +1,316 @@
import { useState, useEffect, useMemo, useRef } from "react";
import {
useGetCategoryProductsQuery,
useLazyGetAllCategoryProductsPaginatedQuery,
} from "../../../app/api/categories";
import { useLazyGetBrandProductsQuery } from "../../../app/api/brandsApi";
import { useLazyGetCollectionProductsPaginatedQuery } from "../../../app/api/collectionsApi";
const useCategoryProducts = ({
categoryId,
collectionId,
brandId,
selectedCategory,
isSubCategory,
currentPage,
selectedFilterCategory,
selectedFilterBrand,
minPrice,
maxPrice,
searchQuery,
}) => {
const [products, setProducts] = useState([]);
const [hasMore, setHasMore] = useState(true);
const isFetchingRef = useRef(false);
const lastFetchKeyRef = useRef(null);
const abortControllerRef = useRef(null);
const contextId = useMemo(() => {
const parts = [
selectedFilterCategory && `fcat-${selectedFilterCategory}`,
categoryId && `cat-${categoryId}`,
brandId && `brand-${brandId}`,
collectionId && `col-${collectionId}`,
selectedFilterBrand && `fbrand-${selectedFilterBrand}`,
].filter(Boolean);
return parts.join("|") || "none";
}, [
selectedFilterCategory,
categoryId,
brandId,
collectionId,
selectedFilterBrand,
]);
const fetchParams = useMemo(
() => ({
page: currentPage,
limit: 6,
brands: selectedFilterBrand || undefined,
min_price: minPrice || undefined,
max_price: maxPrice || undefined,
}),
[currentPage, selectedFilterBrand, minPrice, maxPrice]
);
const fetchKey = `${contextId}-p${currentPage}`;
const shouldUseBaseQuery =
categoryId &&
!isSubCategory &&
!searchQuery &&
!selectedFilterCategory &&
!selectedFilterBrand &&
!brandId &&
!collectionId;
const {
data: paginatedCategoryProducts,
isLoading: categoryLoading,
isFetching: categoryFetching,
} = useGetCategoryProductsQuery(
{
categoryId: categoryId,
page: currentPage,
min_price: minPrice || undefined,
max_price: maxPrice || undefined,
},
{
skip: !shouldUseBaseQuery,
}
);
const [
fetchCategoryPaginated,
{
data: lazyCategoryProducts,
isLoading: lazyCategoryLoading,
isFetching: lazyCategoryFetching,
reset: resetCategoryPaginated,
},
] = useLazyGetAllCategoryProductsPaginatedQuery();
const [
fetchBrandPaginated,
{
data: paginatedBrandProducts,
isLoading: brandPaginatedLoading,
isFetching: brandFetching,
reset: resetBrandPaginated,
},
] = useLazyGetBrandProductsQuery();
const [
fetchCollectionPaginated,
{
data: paginatedCollectionProducts,
isLoading: collectionPaginatedLoading,
isFetching: collectionFetching,
reset: resetCollectionPaginated,
},
] = useLazyGetCollectionProductsPaginatedQuery();
useEffect(() => {
setProducts([]);
setHasMore(true);
resetCategoryPaginated?.();
resetBrandPaginated?.();
resetCollectionPaginated?.();
lastFetchKeyRef.current = null;
isFetchingRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, [
contextId,
resetCategoryPaginated,
resetBrandPaginated,
resetCollectionPaginated,
]);
useEffect(() => {
if (searchQuery) return;
if (lastFetchKeyRef.current === fetchKey) {
return;
}
if (isFetchingRef.current) {
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
isFetchingRef.current = true;
lastFetchKeyRef.current = fetchKey;
const executeFetch = async () => {
try {
if (selectedFilterBrand) {
await fetchBrandPaginated({
id: selectedFilterBrand,
...fetchParams,
});
return;
}
if (selectedFilterCategory) {
await fetchCategoryPaginated({
category: {
id: selectedFilterCategory,
children: [],
},
...fetchParams,
});
return;
}
if (categoryId && isSubCategory) {
await fetchCategoryPaginated({
category: {
id: parseInt(categoryId),
children: [],
},
...fetchParams,
});
return;
}
if (brandId) {
await fetchBrandPaginated({
id: brandId,
...fetchParams,
});
return;
}
if (collectionId) {
await fetchCollectionPaginated({
collectionId,
...fetchParams,
});
return;
}
} catch (error) {
if (error.name !== "AbortError") {
console.error("Fetch error:", error);
}
} finally {
isFetchingRef.current = false;
}
};
executeFetch();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [
fetchKey,
searchQuery,
selectedFilterBrand,
selectedFilterCategory,
categoryId,
isSubCategory,
brandId,
collectionId,
fetchParams,
fetchCategoryPaginated,
fetchBrandPaginated,
fetchCollectionPaginated,
]);
useEffect(() => {
const updateProducts = (newData, hasNextPage) => {
if (!newData || newData.length === 0) {
if (currentPage === 1) {
setProducts([]);
setHasMore(false);
}
return;
}
setProducts((prev) => {
if (currentPage === 1) {
return newData;
}
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = newData.filter((p) => !existingIds.has(p.id));
return newProducts.length > 0 ? [...prev, ...newProducts] : prev;
});
setHasMore(hasNextPage);
};
if (paginatedCategoryProducts && shouldUseBaseQuery) {
updateProducts(
paginatedCategoryProducts.data || [],
!!paginatedCategoryProducts.pagination?.next_page_url
);
return;
}
if (lazyCategoryProducts) {
updateProducts(
lazyCategoryProducts.data || [],
lazyCategoryProducts.pagination?.hasMorePages || false
);
return;
}
// Brand products
if (paginatedBrandProducts) {
updateProducts(
paginatedBrandProducts.data || [],
!!paginatedBrandProducts.pagination?.next_page_url
);
return;
}
if (paginatedCollectionProducts) {
updateProducts(
paginatedCollectionProducts.data || [],
!!paginatedCollectionProducts.pagination?.next_page_url
);
}
}, [
paginatedCategoryProducts,
lazyCategoryProducts,
paginatedBrandProducts,
paginatedCollectionProducts,
currentPage,
shouldUseBaseQuery,
]);
const isLoading =
categoryLoading ||
lazyCategoryLoading ||
brandPaginatedLoading ||
collectionPaginatedLoading ||
categoryFetching ||
lazyCategoryFetching ||
brandFetching ||
collectionFetching;
return {
products,
hasMore,
isLoading,
setProducts,
setHasMore,
};
};
export default useCategoryProducts;

File diff suppressed because it is too large Load Diff