filter price + add wishi list or remove wish list
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { FC } from 'react'
|
||||
import { Brand } from '@/types/products.types'
|
||||
|
||||
interface BrandFilterProps {
|
||||
brands: Brand[]
|
||||
selectedBrand: string
|
||||
onBrandChange: (brandId: string) => void
|
||||
onBrandClear: () => void
|
||||
}
|
||||
|
||||
const BrandFilter: FC<BrandFilterProps> = ({
|
||||
brands,
|
||||
selectedBrand,
|
||||
onBrandChange,
|
||||
onBrandClear,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-2.5 text-sm text-[#4A4A4A] max-h-48 overflow-y-auto'>
|
||||
{brands.map((brand) => (
|
||||
<label key={brand._id} className='flex items-center gap-2.5'>
|
||||
<input
|
||||
type='radio'
|
||||
name='brand'
|
||||
className='size-4 border-border'
|
||||
checked={selectedBrand === brand._id}
|
||||
onChange={() => onBrandChange(brand._id)}
|
||||
/>
|
||||
{brand.title_fa}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBrandClear}
|
||||
className='text-xs text-muted-foreground hover:text-foreground text-right mt-1'
|
||||
>
|
||||
حذف برند
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BrandFilter
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FC } from 'react'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Category, Brand } from '@/types/products.types'
|
||||
import CategoryTree from './CategoryTree'
|
||||
import PriceFilter from './PriceFilter'
|
||||
import StockFilter from './StockFilter'
|
||||
import BrandFilter from './BrandFilter'
|
||||
import WholesaleFilter from './WholesaleFilter'
|
||||
|
||||
interface FilterSectionProps {
|
||||
categories: Category[]
|
||||
brands: Brand[]
|
||||
filterState: {
|
||||
selectedCategories: string[]
|
||||
priceRange: [number, number]
|
||||
inStockOnly: boolean
|
||||
selectedBrand: string
|
||||
wholeSale: boolean
|
||||
}
|
||||
onCategoryChange: (categoryId: string | null) => void
|
||||
onPriceChange: (range: [number, number]) => void
|
||||
onStockChange: (inStock: boolean) => void
|
||||
onBrandChange: (brandId: string) => void
|
||||
onBrandClear: () => void
|
||||
onWholesaleChange: (wholesale: boolean) => void
|
||||
categoryUrl: string
|
||||
priceMinMaxRange: [number, number] // Original min/max from API
|
||||
}
|
||||
|
||||
const FilterSection: FC<FilterSectionProps> = ({
|
||||
categories,
|
||||
brands,
|
||||
filterState,
|
||||
onCategoryChange,
|
||||
onPriceChange,
|
||||
onStockChange,
|
||||
onBrandChange,
|
||||
onBrandClear,
|
||||
onWholesaleChange,
|
||||
categoryUrl,
|
||||
priceMinMaxRange,
|
||||
}) => {
|
||||
return (
|
||||
<div className='mt-3.5'>
|
||||
<Accordion type='multiple' className='w-full'>
|
||||
<AccordionItem value='category'>
|
||||
<AccordionTrigger className='text-[#333333]'>دستهبندی ها</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
selectedId={filterState.selectedCategories[0] ?? null}
|
||||
onSelect={onCategoryChange}
|
||||
categoryUrl={categoryUrl}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='price'>
|
||||
<AccordionTrigger className='text-[#333333]'>قیمت</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<PriceFilter
|
||||
value={filterState.priceRange}
|
||||
onChange={onPriceChange}
|
||||
minMaxRange={priceMinMaxRange}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='status'>
|
||||
<AccordionTrigger className='text-[#333333]'>وضعیت موجودی</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<StockFilter
|
||||
checked={filterState.inStockOnly}
|
||||
onChange={onStockChange}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='brand'>
|
||||
<AccordionTrigger className='text-[#333333]'>برند</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<BrandFilter
|
||||
brands={brands}
|
||||
selectedBrand={filterState.selectedBrand}
|
||||
onBrandChange={onBrandChange}
|
||||
onBrandClear={onBrandClear}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='wholesale'>
|
||||
<AccordionTrigger className='text-[#333333]'>عمدهفروشی</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<WholesaleFilter
|
||||
checked={filterState.wholeSale}
|
||||
onChange={onWholesaleChange}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterSection
|
||||
@@ -1,109 +1,43 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import { FC, useMemo, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { Setting5 } from 'iconsax-react'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import CategoryTree from './CategoryTree'
|
||||
import Modal from '@/components/Modal'
|
||||
import * as api from '../service/Service'
|
||||
import { Brand, Category } from '@/types/products.types'
|
||||
import { useFiltersData } from '../hooks/useFiltersData'
|
||||
import FilterSection from './FilterSection'
|
||||
|
||||
type FiltersProps = {
|
||||
interface FiltersProps {
|
||||
isMobile?: boolean
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
categoryUrl?: string
|
||||
}
|
||||
|
||||
const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose, categoryUrl = 'Organic-supermarket' }) => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const Filters: FC<FiltersProps> = ({
|
||||
isMobile = false,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
categoryUrl = 'Organic-supermarket'
|
||||
}) => {
|
||||
const {
|
||||
filterState,
|
||||
categories,
|
||||
brands,
|
||||
isLoading,
|
||||
priceMinMaxRange,
|
||||
applyCategoryFilter,
|
||||
updatePriceRange,
|
||||
applyStockFilter,
|
||||
applyBrandFilter,
|
||||
clearBrandFilter,
|
||||
applyWholesaleFilter,
|
||||
clearAllFilters,
|
||||
} = useFiltersData({ categoryUrl })
|
||||
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000000])
|
||||
const [inStockOnly, setInStockOnly] = useState<boolean>(false)
|
||||
// const [rating, setRating] = useState<number | null>(null)
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>('')
|
||||
const [wholeSale, setWholeSale] = useState<boolean>(false)
|
||||
|
||||
// ref برای نگهداری timeout ID
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
// تابع اعمال فیلتر قیمت با debounce
|
||||
const applyPriceFilter = useCallback((range: [number, number]) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (range[0] > 0) params.set('minPrice', range[0].toString())
|
||||
else params.delete('minPrice')
|
||||
if (range[1] < 1000000) params.set('maxPrice', range[1].toString())
|
||||
else params.delete('maxPrice')
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}, 1000)
|
||||
}, [searchParams, pathname, router])
|
||||
|
||||
// دریافت brands و categories از API محصولات
|
||||
const { data: productsData } = useQuery({
|
||||
queryKey: ['products-filters', categoryUrl, searchParams.toString()],
|
||||
queryFn: () => {
|
||||
const params = {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
category: searchParams.get('category') || undefined,
|
||||
brand: searchParams.get('brand') || undefined,
|
||||
minPrice: searchParams.get('minPrice') ? Number(searchParams.get('minPrice')) : undefined,
|
||||
maxPrice: searchParams.get('maxPrice') ? Number(searchParams.get('maxPrice')) : undefined,
|
||||
stock: searchParams.get('stock') || undefined,
|
||||
wholeSale: searchParams.get('wholeSale') || undefined,
|
||||
rating: searchParams.get('rating') ? Number(searchParams.get('rating')) : undefined,
|
||||
}
|
||||
return api.getCategoryProducts(categoryUrl, params)
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
const categories: Category[] = useMemo(() => {
|
||||
return productsData?.results?.category?.children ?? []
|
||||
}, [productsData])
|
||||
|
||||
const brands: Brand[] = useMemo(() => {
|
||||
return productsData?.results?.brands ?? []
|
||||
}, [productsData])
|
||||
|
||||
useEffect(() => {
|
||||
const qsCategories = searchParams.get('category')?.split(',').filter(Boolean) ?? []
|
||||
const qsMin = searchParams.get('minPrice') ? Number(searchParams.get('minPrice')) : 0
|
||||
const qsMax = searchParams.get('maxPrice') ? Number(searchParams.get('maxPrice')) : 1000000
|
||||
const qsStock = searchParams.get('stock') === '1'
|
||||
const qsBrand = searchParams.get('brand') ?? ''
|
||||
const qsWholeSale = searchParams.get('wholeSale') === '1'
|
||||
|
||||
setSelectedCategories(qsCategories)
|
||||
setPriceRange([qsMin, qsMax])
|
||||
setInStockOnly(qsStock)
|
||||
setSelectedBrand(qsBrand)
|
||||
setWholeSale(qsWholeSale)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategories([])
|
||||
setPriceRange([0, 1000000])
|
||||
setInStockOnly(false)
|
||||
setSelectedBrand('')
|
||||
setWholeSale(false)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
;['category', 'minPrice', 'maxPrice', 'stock', 'brand', 'wholeSale'].forEach((k) => params.delete(k))
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
const handleClearFilters = () => {
|
||||
clearAllFilters()
|
||||
|
||||
// Close mobile modal after clearing filters
|
||||
if (isMobile && onClose) {
|
||||
@@ -119,157 +53,41 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose,
|
||||
<div className='text-[#666666]'>فیلترها</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-3.5'>
|
||||
<Accordion type='multiple' className='w-full'>
|
||||
<AccordionItem value='category'>
|
||||
<AccordionTrigger className='text-[#333333]'>دستهبندی ها</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
selectedId={selectedCategories[0] ?? null}
|
||||
onSelect={(id) => {
|
||||
const newCategories = id ? [id] : []
|
||||
setSelectedCategories(newCategories)
|
||||
// اعمال فیلتر دستهبندی بلافاصله
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newCategories.length > 0) {
|
||||
params.set('category', newCategories.join(','))
|
||||
} else {
|
||||
params.delete('category')
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}}
|
||||
categoryUrl={categoryUrl}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='price'>
|
||||
<AccordionTrigger className='text-[#333333]'>قیمت</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='px-1'>
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onValueChange={(value) => {
|
||||
setPriceRange(value as [number, number])
|
||||
applyPriceFilter(value as [number, number])
|
||||
}}
|
||||
min={0}
|
||||
max={1000000}
|
||||
step={10000}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between text-sm text-[#666666]'>
|
||||
<span>حداقل: {priceRange[0].toLocaleString('fa-IR')} تومان</span>
|
||||
<span>حداکثر: {priceRange[1].toLocaleString('fa-IR')} تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='status'>
|
||||
<AccordionTrigger className='text-[#333333]'>وضعیت موجودی</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<label className='flex items-center gap-2.5 text-sm text-[#4A4A4A]'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='size-4 rounded border-border'
|
||||
checked={inStockOnly}
|
||||
onChange={(e) => {
|
||||
setInStockOnly(e.target.checked)
|
||||
// اعمال فیلتر موجودی بلافاصله
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (e.target.checked) {
|
||||
params.set('stock', '1')
|
||||
} else {
|
||||
params.delete('stock')
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}}
|
||||
/>
|
||||
فقط کالاهای موجود
|
||||
</label>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='brand'>
|
||||
<AccordionTrigger className='text-[#333333]'>برند</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className='flex flex-col gap-2.5 text-sm text-[#4A4A4A] max-h-48 overflow-y-auto'>
|
||||
{brands.map((brand) => (
|
||||
<label key={brand._id} className='flex items-center gap-2.5'>
|
||||
<input
|
||||
type='radio'
|
||||
name='brand'
|
||||
className='size-4 border-border'
|
||||
checked={selectedBrand === brand._id}
|
||||
onChange={() => {
|
||||
setSelectedBrand(brand._id)
|
||||
// اعمال فیلتر برند بلافاصله
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('brand', brand._id)
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}}
|
||||
/>
|
||||
{brand.title_fa}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSelectedBrand('')
|
||||
// حذف فیلتر برند بلافاصله
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('brand')
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}}
|
||||
className='text-xs text-muted-foreground hover:text-foreground text-right mt-1'
|
||||
>
|
||||
حذف برند
|
||||
</button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='wholesale'>
|
||||
<AccordionTrigger className='text-[#333333]'>عمدهفروشی</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<label className='flex items-center gap-2.5 text-sm text-[#4A4A4A]'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='size-4 rounded border-border'
|
||||
checked={wholeSale}
|
||||
onChange={(e) => {
|
||||
setWholeSale(e.target.checked)
|
||||
// اعمال فیلتر عمدهفروشی بلافاصله
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (e.target.checked) {
|
||||
params.set('wholeSale', '1')
|
||||
} else {
|
||||
params.delete('wholeSale')
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
}}
|
||||
/>
|
||||
فقط کالاهای عمدهفروشی
|
||||
</label>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
</div>
|
||||
<FilterSection
|
||||
categories={categories}
|
||||
brands={brands}
|
||||
filterState={filterState}
|
||||
onCategoryChange={applyCategoryFilter}
|
||||
onPriceChange={updatePriceRange}
|
||||
onStockChange={applyStockFilter}
|
||||
onBrandChange={applyBrandFilter}
|
||||
onBrandClear={clearBrandFilter}
|
||||
onWholesaleChange={applyWholesaleFilter}
|
||||
categoryUrl={categoryUrl}
|
||||
priceMinMaxRange={priceMinMaxRange}
|
||||
/>
|
||||
|
||||
<div className='mt-4'>
|
||||
<Button variant='outline' onClick={clearFilters} className='w-full'>حذف همه فیلترها</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClearFilters}
|
||||
className='w-full'
|
||||
disabled={isLoading}
|
||||
>
|
||||
حذف همه فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Modal open={isOpen} close={onClose || (() => { })} title="فیلترها" isHeader={true}>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
close={onClose || (() => { })}
|
||||
title="فیلترها"
|
||||
isHeader={true}
|
||||
>
|
||||
<div className='max-h-[70vh] overflow-y-auto'>
|
||||
<FilterContent />
|
||||
</div>
|
||||
@@ -278,7 +96,7 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-[270px] hidden lg:block border border-border rounded-[10px] p-5'>
|
||||
<div className='w-[270px] hidden lg:block border border-border rounded-[10px] p-5 h-fit'>
|
||||
<FilterContent />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
|
||||
interface PriceFilterProps {
|
||||
value: [number, number]
|
||||
onChange: (value: [number, number]) => void
|
||||
minMaxRange: [number, number] // Original min/max from API
|
||||
}
|
||||
|
||||
const PriceFilter: FC<PriceFilterProps> = ({ value, onChange, minMaxRange }) => {
|
||||
|
||||
const [minValue, setMinValue] = useState(value[0])
|
||||
const [maxValue, setMaxValue] = useState(value[1])
|
||||
const [hasUserChanged, setHasUserChanged] = useState(false)
|
||||
|
||||
const handleChange = (newValue: [number, number]) => {
|
||||
const [min, max] = newValue
|
||||
if (min === minValue && max === maxValue) {
|
||||
return
|
||||
}
|
||||
setMinValue(min)
|
||||
setMaxValue(max)
|
||||
setHasUserChanged(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasUserChanged && minValue && maxValue) {
|
||||
const timeout = setTimeout(() => {
|
||||
onChange([minValue, maxValue])
|
||||
}, 2000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [minValue, maxValue, onChange, hasUserChanged])
|
||||
|
||||
// Update current values when prop changes
|
||||
useEffect(() => {
|
||||
setMinValue(value[0])
|
||||
setMaxValue(value[1])
|
||||
setHasUserChanged(false)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='px-1'>
|
||||
<Slider
|
||||
value={[minValue, maxValue]}
|
||||
onValueChange={(newValue) => handleChange(newValue as [number, number])}
|
||||
min={minMaxRange[0]}
|
||||
max={minMaxRange[1]}
|
||||
// step={10000}
|
||||
className='w-full [&_[data-slot=slider-thumb]:first-of-type]:order-2 [&_[data-slot=slider-thumb]:last-of-type]:order-1'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between text-sm text-[#666666]'>
|
||||
<span>{maxValue.toLocaleString('fa-IR')} تومان</span>
|
||||
<span>{minValue.toLocaleString('fa-IR')} تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriceFilter
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
interface StockFilterProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const StockFilter: FC<StockFilterProps> = ({ checked, onChange }) => {
|
||||
return (
|
||||
<label className='flex items-center gap-2.5 text-sm text-[#4A4A4A]'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='size-4 rounded border-border'
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
فقط کالاهای موجود
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default StockFilter
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
interface WholesaleFilterProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const WholesaleFilter: FC<WholesaleFilterProps> = ({ checked, onChange }) => {
|
||||
return (
|
||||
<label className='flex items-center gap-2.5 text-sm text-[#4A4A4A]'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='size-4 rounded border-border'
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
فقط کالاهای عمدهفروشی
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default WholesaleFilter
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCategoryProductsData } from "./useProductsData";
|
||||
import { Brand, Category } from "@/types/products.types";
|
||||
|
||||
export interface FilterState {
|
||||
selectedCategories: string[];
|
||||
priceRange: [number, number];
|
||||
inStockOnly: boolean;
|
||||
selectedBrand: string;
|
||||
wholeSale: boolean;
|
||||
}
|
||||
|
||||
export interface UseFiltersDataProps {
|
||||
categoryUrl: string;
|
||||
}
|
||||
|
||||
export const useFiltersData = ({ categoryUrl }: UseFiltersDataProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// State management
|
||||
const [filterState, setFilterState] = useState<FilterState>({
|
||||
selectedCategories: [],
|
||||
priceRange: [0, 1000000], // Default values, will be updated from API
|
||||
inStockOnly: false,
|
||||
selectedBrand: "",
|
||||
wholeSale: false,
|
||||
});
|
||||
|
||||
// Ref for debouncing price filter
|
||||
const priceFilterTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Get filter parameters from URL
|
||||
const filterParams = useMemo(
|
||||
() => ({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
category: searchParams.get("category") || undefined,
|
||||
brand: searchParams.get("brand") || undefined,
|
||||
minPrice: searchParams.get("minPrice")
|
||||
? Number(searchParams.get("minPrice"))
|
||||
: undefined,
|
||||
maxPrice: searchParams.get("maxPrice")
|
||||
? Number(searchParams.get("maxPrice"))
|
||||
: undefined,
|
||||
stock: searchParams.get("stock") || undefined,
|
||||
wholeSale: searchParams.get("wholeSale") || undefined,
|
||||
rating: searchParams.get("rating")
|
||||
? Number(searchParams.get("rating"))
|
||||
: undefined,
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
// Fetch data for filters
|
||||
const { data: productsData, isLoading } = useCategoryProductsData(
|
||||
categoryUrl,
|
||||
filterParams
|
||||
);
|
||||
|
||||
// Extract categories and brands from API response
|
||||
const categories: Category[] = useMemo(() => {
|
||||
return productsData?.results?.category?.children ?? [];
|
||||
}, [productsData]);
|
||||
|
||||
const brands: Brand[] = useMemo(() => {
|
||||
return productsData?.results?.brands ?? [];
|
||||
}, [productsData]);
|
||||
|
||||
// Extract price range from API response
|
||||
const apiPriceRange = useMemo(() => {
|
||||
return productsData?.results?.filters?.priceRange;
|
||||
}, [productsData]);
|
||||
|
||||
// Update price range from API when data is available
|
||||
useEffect(() => {
|
||||
if (apiPriceRange) {
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
priceRange: [apiPriceRange.minPrice, apiPriceRange.maxPrice],
|
||||
}));
|
||||
}
|
||||
}, [apiPriceRange]);
|
||||
|
||||
// Initialize state from URL parameters
|
||||
useEffect(() => {
|
||||
const qsCategories =
|
||||
searchParams.get("category")?.split(",").filter(Boolean) ?? [];
|
||||
const qsMin = searchParams.get("minPrice")
|
||||
? Number(searchParams.get("minPrice"))
|
||||
: apiPriceRange?.minPrice ?? 0;
|
||||
const qsMax = searchParams.get("maxPrice")
|
||||
? Number(searchParams.get("maxPrice"))
|
||||
: apiPriceRange?.maxPrice ?? 1000000;
|
||||
const qsStock = searchParams.get("stock") === "1";
|
||||
const qsBrand = searchParams.get("brand") ?? "";
|
||||
const qsWholeSale = searchParams.get("wholeSale") === "1";
|
||||
|
||||
setFilterState({
|
||||
selectedCategories: qsCategories,
|
||||
priceRange: [qsMin, qsMax],
|
||||
inStockOnly: qsStock,
|
||||
selectedBrand: qsBrand,
|
||||
wholeSale: qsWholeSale,
|
||||
});
|
||||
}, [searchParams, apiPriceRange]);
|
||||
|
||||
// Update URL parameters
|
||||
const updateUrlParams = useCallback(
|
||||
(updates: Partial<Record<string, string | null>>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === "") {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
},
|
||||
[searchParams, pathname, router]
|
||||
);
|
||||
|
||||
// Apply price filter with debouncing
|
||||
const applyPriceFilter = useCallback(
|
||||
(range: [number, number]) => {
|
||||
if (priceFilterTimeoutRef.current) {
|
||||
clearTimeout(priceFilterTimeoutRef.current);
|
||||
}
|
||||
|
||||
priceFilterTimeoutRef.current = setTimeout(() => {
|
||||
const updates: Partial<Record<string, string | null>> = {};
|
||||
|
||||
if (range[0] > 0) {
|
||||
updates.minPrice = range[0].toString();
|
||||
} else {
|
||||
updates.minPrice = null;
|
||||
}
|
||||
|
||||
const maxPrice = apiPriceRange?.maxPrice ?? 1000000;
|
||||
if (range[1] < maxPrice) {
|
||||
updates.maxPrice = range[1].toString();
|
||||
} else {
|
||||
updates.maxPrice = null;
|
||||
}
|
||||
|
||||
updateUrlParams(updates);
|
||||
}, 1000);
|
||||
},
|
||||
[updateUrlParams, apiPriceRange]
|
||||
);
|
||||
|
||||
// Apply category filter
|
||||
const applyCategoryFilter = useCallback(
|
||||
(categoryId: string | null) => {
|
||||
const newCategories = categoryId ? [categoryId] : [];
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
selectedCategories: newCategories,
|
||||
}));
|
||||
|
||||
updateUrlParams({
|
||||
category: newCategories.length > 0 ? newCategories.join(",") : null,
|
||||
});
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
// Apply stock filter
|
||||
const applyStockFilter = useCallback(
|
||||
(inStock: boolean) => {
|
||||
setFilterState((prev) => ({ ...prev, inStockOnly: inStock }));
|
||||
updateUrlParams({ stock: inStock ? "1" : null });
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
// Apply brand filter
|
||||
const applyBrandFilter = useCallback(
|
||||
(brandId: string) => {
|
||||
setFilterState((prev) => ({ ...prev, selectedBrand: brandId }));
|
||||
updateUrlParams({ brand: brandId });
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
// Clear brand filter
|
||||
const clearBrandFilter = useCallback(() => {
|
||||
setFilterState((prev) => ({ ...prev, selectedBrand: "" }));
|
||||
updateUrlParams({ brand: null });
|
||||
}, [updateUrlParams]);
|
||||
|
||||
// Apply wholesale filter
|
||||
const applyWholesaleFilter = useCallback(
|
||||
(wholesale: boolean) => {
|
||||
setFilterState((prev) => ({ ...prev, wholeSale: wholesale }));
|
||||
updateUrlParams({ wholeSale: wholesale ? "1" : null });
|
||||
},
|
||||
[updateUrlParams]
|
||||
);
|
||||
|
||||
// Clear all filters
|
||||
const clearAllFilters = useCallback(() => {
|
||||
const defaultMinPrice = apiPriceRange?.minPrice ?? 0;
|
||||
const defaultMaxPrice = apiPriceRange?.maxPrice ?? 1000000;
|
||||
|
||||
setFilterState({
|
||||
selectedCategories: [],
|
||||
priceRange: [defaultMinPrice, defaultMaxPrice],
|
||||
inStockOnly: false,
|
||||
selectedBrand: "",
|
||||
wholeSale: false,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
const filterKeys = [
|
||||
"category",
|
||||
"minPrice",
|
||||
"maxPrice",
|
||||
"stock",
|
||||
"brand",
|
||||
"wholeSale",
|
||||
];
|
||||
filterKeys.forEach((key) => params.delete(key));
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}, [searchParams, pathname, router, apiPriceRange]);
|
||||
|
||||
// Update price range in state
|
||||
const updatePriceRange = useCallback(
|
||||
(range: [number, number]) => {
|
||||
setFilterState((prev) => ({ ...prev, priceRange: range }));
|
||||
applyPriceFilter(range);
|
||||
},
|
||||
[applyPriceFilter]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
filterState,
|
||||
categories,
|
||||
brands,
|
||||
isLoading,
|
||||
priceMinMaxRange: apiPriceRange
|
||||
? ([apiPriceRange.minPrice, apiPriceRange.maxPrice] as [number, number])
|
||||
: ([0, 1000000] as [number, number]),
|
||||
|
||||
// Actions
|
||||
applyCategoryFilter,
|
||||
updatePriceRange,
|
||||
applyStockFilter,
|
||||
applyBrandFilter,
|
||||
clearBrandFilter,
|
||||
applyWholesaleFilter,
|
||||
clearAllFilters,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/Service";
|
||||
import { ProductsResponse } from "@/types/products.types";
|
||||
|
||||
@@ -34,3 +34,15 @@ export const useCategoryProductsData = (
|
||||
queryFn: () => api.getCategoryProducts(categoryUrl, params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddWishlist = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.addWishlist,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveWishlist = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.removeWishlist,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "@/config/axios";
|
||||
import { ProductsResponse } from "@/types/products.types";
|
||||
import { WishListType } from "../types/Types";
|
||||
|
||||
export const getProducts = async (params?: {
|
||||
page?: number;
|
||||
@@ -45,3 +46,19 @@ export const getCategoryProducts = async (
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const addWishlist = async (params: WishListType) => {
|
||||
const { data } = await axios.post(
|
||||
`/product/${params.productId}/wishlist/add`,
|
||||
params
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const removeWishlist = async (params: WishListType) => {
|
||||
const { data } = await axios.post(
|
||||
`/product/${params.productId}/wishlist/remove`,
|
||||
params
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type WishListType = {
|
||||
productId: string;
|
||||
variantId: string;
|
||||
};
|
||||
@@ -2,7 +2,8 @@ import { IProduct } from '@/types/landing.types'
|
||||
import { NumberFormat } from '@/config/func'
|
||||
import { Heart } from 'iconsax-react'
|
||||
import Image from 'next/image'
|
||||
import { FC } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useAddWishlist, useRemoveWishlist } from '@/app/products/hooks/useProductsData'
|
||||
|
||||
type Props = {
|
||||
item?: IProduct
|
||||
@@ -10,6 +11,25 @@ type Props = {
|
||||
|
||||
|
||||
const ProductCard: FC<Props> = ({ item }) => {
|
||||
|
||||
const [isWished, setIsWished] = useState(item?.isWishlist)
|
||||
|
||||
const { mutate: addWishlist } = useAddWishlist()
|
||||
const { mutate: removeWishlist } = useRemoveWishlist()
|
||||
|
||||
|
||||
const handleWish = () => {
|
||||
if (!item?._id || !item?.variants[0]?._id) return
|
||||
|
||||
if (isWished) {
|
||||
removeWishlist({ productId: item._id.toString(), variantId: item.variants[0]._id })
|
||||
setIsWished(false)
|
||||
} else {
|
||||
addWishlist({ productId: item._id.toString(), variantId: item.variants[0]._id })
|
||||
setIsWished(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (item) return (
|
||||
<div className='bg-white rounded-[20px] sm:rounded-[40px] p-4 sm:p-6 w-full max-w-[240px] sm:max-w-[280px] mx-auto shadow'>
|
||||
<div className='flex justify-center'>
|
||||
@@ -64,7 +84,7 @@ const ProductCard: FC<Props> = ({ item }) => {
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Heart size={20} color='#000' />
|
||||
<Heart onClick={handleWish} variant={isWished ? 'Bold' : 'Outline'} size={20} color='#000' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface IProduct {
|
||||
url: string;
|
||||
default_variant: IDefaultVariant;
|
||||
variants: IVariant[];
|
||||
isWishlist: boolean;
|
||||
}
|
||||
|
||||
export interface ICategory {
|
||||
|
||||
Reference in New Issue
Block a user