api attachment product list + filter
This commit is contained in:
Generated
+46
@@ -12,6 +12,7 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tanstack/react-query": "^5.84.1",
|
"@tanstack/react-query": "^5.84.1",
|
||||||
"@tanstack/react-query-devtools": "^5.85.5",
|
"@tanstack/react-query-devtools": "^5.85.5",
|
||||||
@@ -999,6 +1000,12 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||||
@@ -1288,6 +1295,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-slider": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tanstack/react-query": "^5.84.1",
|
"@tanstack/react-query": "^5.84.1",
|
||||||
"@tanstack/react-query-devtools": "^5.85.5",
|
"@tanstack/react-query-devtools": "^5.85.5",
|
||||||
|
|||||||
+132
-14
@@ -12,13 +12,103 @@ import {
|
|||||||
} from '@/components/ui/breadcrumb'
|
} from '@/components/ui/breadcrumb'
|
||||||
import Filters from '../components/Filters'
|
import Filters from '../components/Filters'
|
||||||
import Sorts from '../components/Sorts'
|
import Sorts from '../components/Sorts'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
import GridWrapper from '@/components/GridWrapper'
|
import GridWrapper from '@/components/GridWrapper'
|
||||||
import ProductCard from '@/components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import { Setting5 } from 'iconsax-react'
|
import { Setting5 } from 'iconsax-react'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useCategoryProductsData } from '../hooks/useProductsData'
|
||||||
|
import { IProduct } from '@/types/landing.types'
|
||||||
|
import { Product } from '@/types/products.types'
|
||||||
|
import { useSearchParams, useParams } from 'next/navigation'
|
||||||
|
|
||||||
const Products: NextPage = () => {
|
const Products: NextPage = () => {
|
||||||
const [showMobileFilters, setShowMobileFilters] = useState(false)
|
const [showMobileFilters, setShowMobileFilters] = useState(false)
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const params = useParams()
|
||||||
|
const [filterParams, setFilterParams] = useState({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sort: '3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// استخراج categoryUrl از URL
|
||||||
|
const categoryUrl = params.id as string
|
||||||
|
|
||||||
|
// خواندن پارامترها از URL
|
||||||
|
useEffect(() => {
|
||||||
|
const params = {
|
||||||
|
page: parseInt(searchParams.get('page') || '1'),
|
||||||
|
limit: parseInt(searchParams.get('limit') || '20'),
|
||||||
|
sort: searchParams.get('sort') || '3',
|
||||||
|
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,
|
||||||
|
attributes: searchParams.get('attributes')?.split(',').filter(Boolean) || undefined,
|
||||||
|
sizes: searchParams.get('sizes')?.split(',').filter(Boolean).map(Number) || undefined,
|
||||||
|
meterages: searchParams.get('meterages')?.split(',').filter(Boolean).map(Number) || undefined,
|
||||||
|
}
|
||||||
|
setFilterParams(params)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// دریافت دادههای محصولات از API بر اساس categoryUrl
|
||||||
|
const { data, isLoading, error } = useCategoryProductsData(categoryUrl, filterParams)
|
||||||
|
|
||||||
|
// تبدیل Product به IProduct
|
||||||
|
const convertToIProduct = (product: Product): IProduct => {
|
||||||
|
// اطمینان از وجود default_variant
|
||||||
|
const defaultVariant = product.default_variant || product.variants?.[0];
|
||||||
|
const price = defaultVariant?.price || {
|
||||||
|
order_limit: 1,
|
||||||
|
retailPrice: 0,
|
||||||
|
is_specialSale: false,
|
||||||
|
discount_percent: 0,
|
||||||
|
specialSale_order_limit: null,
|
||||||
|
specialSale_quantity: null,
|
||||||
|
specialSale_endDate: null,
|
||||||
|
selling_price: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: product._id,
|
||||||
|
title_fa: product.title_fa,
|
||||||
|
title_en: product.title_en,
|
||||||
|
seoTitle: product.seoTitle || null,
|
||||||
|
seoDescription: product.seoDescription || null,
|
||||||
|
model: '',
|
||||||
|
description: product.description,
|
||||||
|
tags: product.tags || [],
|
||||||
|
shop: defaultVariant?.shop || {
|
||||||
|
_id: '',
|
||||||
|
shopName: '',
|
||||||
|
shopDescription: '',
|
||||||
|
logo: ''
|
||||||
|
},
|
||||||
|
status: defaultVariant?.market_status || 'active',
|
||||||
|
imagesUrl: product.imagesUrl,
|
||||||
|
url: product.url,
|
||||||
|
default_variant: {
|
||||||
|
_id: defaultVariant?._id || product._id.toString(),
|
||||||
|
market_status: defaultVariant?.market_status || 'active',
|
||||||
|
price: price,
|
||||||
|
stock: defaultVariant?.stock || 0,
|
||||||
|
isFreeShip: defaultVariant?.isFreeShip || false,
|
||||||
|
isWholeSale: defaultVariant?.isWholeSale || false
|
||||||
|
},
|
||||||
|
variants: (product.variants || []).map(variant => ({
|
||||||
|
...variant,
|
||||||
|
market_status: variant.market_status as "Marketable" | "Unmarketable" | "OutOfStock",
|
||||||
|
shipmentMethod: variant.shipmentMethod.map(method => ({
|
||||||
|
...method,
|
||||||
|
deliveryType: method.deliveryType as "Standard" | "SameDay" | "Express"
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-14 px-4 sm:px-6 md:px-8 lg:px-20">
|
<div className="mt-14 px-4 sm:px-6 md:px-8 lg:px-20">
|
||||||
@@ -27,14 +117,22 @@ const Products: NextPage = () => {
|
|||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
|
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
|
{data?.results.breadcrumb?.map((item, index) => (
|
||||||
|
<div key={item._id} className="flex items-center">
|
||||||
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
|
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink href="/products">بهداشت خانگی</BreadcrumbLink>
|
{index === data.results.breadcrumb.length - 1 ? (
|
||||||
</BreadcrumbItem>
|
<BreadcrumbPage className="text-foreground">
|
||||||
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
|
{item.title_fa}
|
||||||
<BreadcrumbItem>
|
</BreadcrumbPage>
|
||||||
<BreadcrumbPage className="text-foreground">مواد شوینده</BreadcrumbPage>
|
) : (
|
||||||
|
<BreadcrumbLink href={`/category/${item.title_en}`}>
|
||||||
|
{item.title_fa}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
@@ -51,26 +149,46 @@ const Products: NextPage = () => {
|
|||||||
|
|
||||||
<div className='mt-6 sm:mt-8 md:mt-12 lg:mt-8 flex flex-col lg:flex-row gap-4 sm:gap-6'>
|
<div className='mt-6 sm:mt-8 md:mt-12 lg:mt-8 flex flex-col lg:flex-row gap-4 sm:gap-6'>
|
||||||
{/* Desktop Filters */}
|
{/* Desktop Filters */}
|
||||||
<Filters />
|
<Filters categoryUrl={categoryUrl} />
|
||||||
|
|
||||||
{/* Mobile Filters Modal */}
|
{/* Mobile Filters Modal */}
|
||||||
<Filters
|
<Filters
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
isOpen={showMobileFilters}
|
isOpen={showMobileFilters}
|
||||||
onClose={() => setShowMobileFilters(false)}
|
onClose={() => setShowMobileFilters(false)}
|
||||||
|
categoryUrl={categoryUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<Sorts />
|
<Sorts data={data} />
|
||||||
|
|
||||||
<div className='mt-6 sm:mt-8 md:mt-10'>
|
<div className='mt-6 sm:mt-8 md:mt-10'>
|
||||||
<GridWrapper desktop={4} mobile={2} gapMobile={3} gapDesktop={6}>
|
{isLoading ? (
|
||||||
<ProductCard />
|
<div className="text-center py-8">
|
||||||
<ProductCard />
|
<div className="text-gray-500">در حال بارگذاری...</div>
|
||||||
<ProductCard />
|
|
||||||
<ProductCard />
|
|
||||||
</GridWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-red-500">خطا در بارگذاری محصولات</div>
|
||||||
|
</div>
|
||||||
|
) : data?.results.products ? (
|
||||||
|
<GridWrapper desktop={4} mobile={2} gapMobile={3} gapDesktop={6}>
|
||||||
|
{data.results.products.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product._id}
|
||||||
|
item={convertToIProduct(product)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</GridWrapper>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-gray-500">محصولی یافت نشد</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Pagination pager={data?.results?.pager} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,27 +2,53 @@
|
|||||||
|
|
||||||
import { FC, useCallback, useMemo, useState } from 'react'
|
import { FC, useCallback, useMemo, useState } from 'react'
|
||||||
import { ArrowDown2 } from 'iconsax-react'
|
import { ArrowDown2 } from 'iconsax-react'
|
||||||
import { Category } from '@/share/types/SharedTypes'
|
import { Category } from '@/types/products.types'
|
||||||
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
|
categoryUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryTree: FC<Props> = ({ categories, selectedId, onSelect }) => {
|
const CategoryTree: FC<Props> = ({ categories, selectedId, onSelect, categoryUrl = 'Organic-supermarket' }) => {
|
||||||
|
const router = useRouter()
|
||||||
const roots = useMemo(() => categories || [], [categories])
|
const roots = useMemo(() => categories || [], [categories])
|
||||||
|
|
||||||
|
const findCategoryById = useCallback((cats: Category[], id: string): Category | null => {
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (cat._id === id) return cat
|
||||||
|
if (cat.children) {
|
||||||
|
const found = findCategoryById(cat.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCategorySelect = useCallback((id: string | null) => {
|
||||||
|
onSelect(id)
|
||||||
|
// تغییر URL به دستهبندی انتخاب شده
|
||||||
|
if (id) {
|
||||||
|
const selectedCategory = findCategoryById(categories, id)
|
||||||
|
if (selectedCategory) {
|
||||||
|
// ساخت URL به فرمت products/Organic-supermarket?sort=1&page=1
|
||||||
|
const categorySlug = selectedCategory.title_en.replace(/\s+/g, '-')
|
||||||
|
const newUrl = `/products/${categorySlug}?sort=1&page=1`
|
||||||
|
router.push(newUrl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// اگر هیچ دستهبندی انتخاب نشده، به دستهبندی فعلی برگرد
|
||||||
|
const newUrl = `/products/${categoryUrl}?sort=1&page=1`
|
||||||
|
router.push(newUrl)
|
||||||
|
}
|
||||||
|
}, [onSelect, router, categories, findCategoryById, categoryUrl])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-0'>
|
<div className='space-y-0'>
|
||||||
<Row
|
|
||||||
label='همه کالا ها'
|
|
||||||
isLeaf
|
|
||||||
isSelected={selectedId === null}
|
|
||||||
onClick={() => onSelect(null)}
|
|
||||||
/>
|
|
||||||
{roots.map((cat) => (
|
{roots.map((cat) => (
|
||||||
<Node key={cat._id} node={cat} selectedId={selectedId} onSelect={onSelect} depth={0} />
|
<Node key={cat._id} node={cat} selectedId={selectedId} onSelect={handleCategorySelect} depth={0} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -40,8 +66,11 @@ const Node: FC<NodeProps> = ({ node, selectedId, onSelect, depth }) => {
|
|||||||
const hasChildren = (node.children?.length ?? 0) > 0
|
const hasChildren = (node.children?.length ?? 0) > 0
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (hasChildren) setOpen((p) => !p)
|
if (hasChildren) {
|
||||||
else onSelect(node._id)
|
setOpen((p) => !p)
|
||||||
|
} else {
|
||||||
|
onSelect(node._id)
|
||||||
|
}
|
||||||
}, [hasChildren, node._id, onSelect])
|
}, [hasChildren, node._id, onSelect])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,8 +92,8 @@ const Node: FC<NodeProps> = ({ node, selectedId, onSelect, depth }) => {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
/>)
|
/>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,93 +1,116 @@
|
|||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FC, useMemo, useState, useEffect } from 'react'
|
import { FC, useMemo, useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Setting5 } from 'iconsax-react'
|
import { Setting5 } from 'iconsax-react'
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
import Input from '@/components/Input'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getCategories } from '@/share/service/ShareService'
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import CategoryTree from './CategoryTree'
|
import CategoryTree from './CategoryTree'
|
||||||
import { Category } from '@/share/types/SharedTypes'
|
|
||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
|
import * as api from '../service/Service'
|
||||||
|
import { Brand, Category } from '@/types/products.types'
|
||||||
|
|
||||||
type FiltersProps = {
|
type FiltersProps = {
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
categoryUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }) => {
|
const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose, categoryUrl = 'Organic-supermarket' }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||||
const [minPrice, setMinPrice] = useState<string>('')
|
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000000])
|
||||||
const [maxPrice, setMaxPrice] = useState<string>('')
|
|
||||||
const [inStockOnly, setInStockOnly] = useState<boolean>(false)
|
const [inStockOnly, setInStockOnly] = useState<boolean>(false)
|
||||||
const [rating, setRating] = useState<number | null>(null)
|
// const [rating, setRating] = useState<number | null>(null)
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState<string>('')
|
||||||
|
const [wholeSale, setWholeSale] = useState<boolean>(false)
|
||||||
|
|
||||||
const { data: categoriesData } = useQuery({
|
// ref برای نگهداری timeout ID
|
||||||
queryKey: ['categories'],
|
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||||
queryFn: getCategories,
|
|
||||||
staleTime: 1000 * 60 * 10,
|
// تابع اعمال فیلتر قیمت با 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(() => {
|
const categories: Category[] = useMemo(() => {
|
||||||
return categoriesData?.results?.data ?? []
|
return productsData?.results?.category?.children ?? []
|
||||||
}, [categoriesData])
|
}, [productsData])
|
||||||
|
|
||||||
|
const brands: Brand[] = useMemo(() => {
|
||||||
|
return productsData?.results?.brands ?? []
|
||||||
|
}, [productsData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const qsCategories = searchParams.get('category')?.split(',').filter(Boolean) ?? []
|
const qsCategories = searchParams.get('category')?.split(',').filter(Boolean) ?? []
|
||||||
const qsMin = searchParams.get('minPrice') ?? ''
|
const qsMin = searchParams.get('minPrice') ? Number(searchParams.get('minPrice')) : 0
|
||||||
const qsMax = searchParams.get('maxPrice') ?? ''
|
const qsMax = searchParams.get('maxPrice') ? Number(searchParams.get('maxPrice')) : 1000000
|
||||||
const qsStock = searchParams.get('inStock') === '1'
|
const qsStock = searchParams.get('stock') === '1'
|
||||||
const qsRating = searchParams.get('rating')
|
const qsBrand = searchParams.get('brand') ?? ''
|
||||||
|
const qsWholeSale = searchParams.get('wholeSale') === '1'
|
||||||
|
|
||||||
setSelectedCategories(qsCategories)
|
setSelectedCategories(qsCategories)
|
||||||
setMinPrice(qsMin)
|
setPriceRange([qsMin, qsMax])
|
||||||
setMaxPrice(qsMax)
|
|
||||||
setInStockOnly(qsStock)
|
setInStockOnly(qsStock)
|
||||||
setRating(qsRating ? Number(qsRating) : null)
|
setSelectedBrand(qsBrand)
|
||||||
|
setWholeSale(qsWholeSale)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyFilters = () => {
|
const clearFilters = () => {
|
||||||
|
setSelectedCategories([])
|
||||||
|
setPriceRange([0, 1000000])
|
||||||
|
setInStockOnly(false)
|
||||||
|
setSelectedBrand('')
|
||||||
|
setWholeSale(false)
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
if (selectedCategories.length > 0) {
|
;['category', 'minPrice', 'maxPrice', 'stock', 'brand', 'wholeSale'].forEach((k) => params.delete(k))
|
||||||
params.set('category', selectedCategories.join(','))
|
|
||||||
} else {
|
|
||||||
params.delete('category')
|
|
||||||
}
|
|
||||||
if (minPrice) params.set('minPrice', minPrice)
|
|
||||||
else params.delete('minPrice')
|
|
||||||
if (maxPrice) params.set('maxPrice', maxPrice)
|
|
||||||
else params.delete('maxPrice')
|
|
||||||
if (inStockOnly) params.set('inStock', '1')
|
|
||||||
else params.delete('inStock')
|
|
||||||
if (rating) params.set('rating', String(rating))
|
|
||||||
else params.delete('rating')
|
|
||||||
router.push(`${pathname}?${params.toString()}`)
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
|
||||||
// Close mobile modal after applying filters
|
// Close mobile modal after clearing filters
|
||||||
if (isMobile && onClose) {
|
if (isMobile && onClose) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSelectedCategories([])
|
|
||||||
setMinPrice('')
|
|
||||||
setMaxPrice('')
|
|
||||||
setInStockOnly(false)
|
|
||||||
setRating(null)
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
;['category', 'minPrice', 'maxPrice', 'inStock', 'rating'].forEach((k) => params.delete(k))
|
|
||||||
router.push(`${pathname}?${params.toString()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilterContent = () => (
|
const FilterContent = () => (
|
||||||
<>
|
<>
|
||||||
@@ -104,7 +127,19 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
|
|||||||
<CategoryTree
|
<CategoryTree
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedId={selectedCategories[0] ?? null}
|
selectedId={selectedCategories[0] ?? null}
|
||||||
onSelect={(id) => setSelectedCategories(id ? [id] : [])}
|
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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -112,24 +147,25 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
|
|||||||
<AccordionItem value='price'>
|
<AccordionItem value='price'>
|
||||||
<AccordionTrigger className='text-[#333333]'>قیمت</AccordionTrigger>
|
<AccordionTrigger className='text-[#333333]'>قیمت</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className='grid grid-cols-2 gap-2.5'>
|
<div className='space-y-4'>
|
||||||
<Input
|
<div className='px-1'>
|
||||||
label='حداقل'
|
<Slider
|
||||||
value={minPrice}
|
value={priceRange}
|
||||||
onChange={(e) => setMinPrice(e.target.value)}
|
onValueChange={(value) => {
|
||||||
placeholder='0'
|
setPriceRange(value as [number, number])
|
||||||
seprator
|
applyPriceFilter(value as [number, number])
|
||||||
inputMode='numeric'
|
}}
|
||||||
/>
|
min={0}
|
||||||
<Input
|
max={1000000}
|
||||||
label='حداکثر'
|
step={10000}
|
||||||
value={maxPrice}
|
className='w-full'
|
||||||
onChange={(e) => setMaxPrice(e.target.value)}
|
|
||||||
placeholder='0'
|
|
||||||
seprator
|
|
||||||
inputMode='numeric'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -141,45 +177,92 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
|
|||||||
type='checkbox'
|
type='checkbox'
|
||||||
className='size-4 rounded border-border'
|
className='size-4 rounded border-border'
|
||||||
checked={inStockOnly}
|
checked={inStockOnly}
|
||||||
onChange={(e) => setInStockOnly(e.target.checked)}
|
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>
|
</label>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value='rating'>
|
<AccordionItem value='brand'>
|
||||||
<AccordionTrigger className='text-[#333333]'>امتیاز خریداران</AccordionTrigger>
|
<AccordionTrigger className='text-[#333333]'>برند</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className='flex flex-col gap-2.5 text-sm text-[#4A4A4A]'>
|
<div className='flex flex-col gap-2.5 text-sm text-[#4A4A4A] max-h-48 overflow-y-auto'>
|
||||||
{[4, 3, 2].map((r) => (
|
{brands.map((brand) => (
|
||||||
<label key={r} className='flex items-center gap-2.5'>
|
<label key={brand._id} className='flex items-center gap-2.5'>
|
||||||
<input
|
<input
|
||||||
type='radio'
|
type='radio'
|
||||||
name='rating'
|
name='brand'
|
||||||
className='size-4 border-border'
|
className='size-4 border-border'
|
||||||
checked={rating === r}
|
checked={selectedBrand === brand._id}
|
||||||
onChange={() => setRating(r)}
|
onChange={() => {
|
||||||
|
setSelectedBrand(brand._id)
|
||||||
|
// اعمال فیلتر برند بلافاصله
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('brand', brand._id)
|
||||||
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{r} ستاره و بالاتر
|
{brand.title_fa}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setRating(null)}
|
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'
|
className='text-xs text-muted-foreground hover:text-foreground text-right mt-1'
|
||||||
>
|
>
|
||||||
حذف امتیاز
|
حذف برند
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</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>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 grid grid-cols-2 gap-2.5'>
|
<div className='mt-4'>
|
||||||
<Button variant='outline' onClick={clearFilters}>حذف</Button>
|
<Button variant='outline' onClick={clearFilters} className='w-full'>حذف همه فیلترها</Button>
|
||||||
<Button onClick={applyFilters}>اعمال</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ArrowLeft2, ArrowRight2 } from 'iconsax-react'
|
||||||
|
import { Pager } from '@/types/products.types'
|
||||||
|
|
||||||
|
type PaginationProps = {
|
||||||
|
pager?: Pager
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pagination: FC<PaginationProps> = ({ pager }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
if (!pager || pager.totalPages <= 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('page', page.toString())
|
||||||
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatePageNumbers = () => {
|
||||||
|
const pages = []
|
||||||
|
const currentPage = pager.page
|
||||||
|
const totalPages = pager.totalPages
|
||||||
|
const maxVisiblePages = 5
|
||||||
|
|
||||||
|
if (totalPages <= maxVisiblePages) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
pages.push('...')
|
||||||
|
pages.push(totalPages)
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pages.push(1)
|
||||||
|
pages.push('...')
|
||||||
|
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pages.push(1)
|
||||||
|
pages.push('...')
|
||||||
|
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
pages.push('...')
|
||||||
|
pages.push(totalPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumbers = generatePageNumbers()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-8">
|
||||||
|
{/* Previous Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(pager.page - 1)}
|
||||||
|
disabled={!pager.prevPage}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ArrowRight2 size={16} />
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{pageNumbers.map((page, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
{page === '...' ? (
|
||||||
|
<span className="px-3 py-2 text-gray-500">...</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={page === pager.page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(page as number)}
|
||||||
|
className="min-w-[40px]"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(pager.page + 1)}
|
||||||
|
disabled={!pager.nextPage}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
<ArrowLeft2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination
|
||||||
@@ -1,23 +1,47 @@
|
|||||||
import { Sort, ArrowDown2 } from 'iconsax-react'
|
import { Sort, ArrowDown2 } from 'iconsax-react'
|
||||||
import { FC, useState, useEffect, useRef } from 'react'
|
import { FC, useState, useEffect, useRef } from 'react'
|
||||||
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
import { ProductsResponse } from '@/types/products.types'
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ key: 'relevant', label: 'مرتبط ترین' },
|
{ key: '1', label: 'مرتبط ترین' },
|
||||||
{ key: 'most_visited', label: 'پربازدیدترین' },
|
{ key: '2', label: 'پربازدیدترین' },
|
||||||
{ key: 'newest', label: 'جدیدترین' },
|
{ key: '3', label: 'جدیدترین' },
|
||||||
{ key: 'best_selling', label: 'پرفروش ترین' },
|
{ key: '4', label: 'پرفروش ترین' },
|
||||||
{ key: 'cheapest', label: 'ارزانترین' },
|
{ key: '5', label: 'ارزانترین' },
|
||||||
{ key: 'most_expensive', label: 'گرانترین' },
|
{ key: '6', label: 'گرانترین' },
|
||||||
{ key: 'customer_suggestion', label: 'پیشنهاد خریداران' },
|
{ key: '7', label: 'پیشنهاد خریداران' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const Sorts: FC = () => {
|
type SortsProps = {
|
||||||
const [selectedSort, setSelectedSort] = useState('newest')
|
data?: ProductsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sorts: FC<SortsProps> = ({ data }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [selectedSort, setSelectedSort] = useState('3') // default to newest
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const selectedOption = sortOptions.find(option => option.key === selectedSort)
|
const selectedOption = sortOptions.find(option => option.key === selectedSort)
|
||||||
|
|
||||||
|
// خواندن sort از URL
|
||||||
|
useEffect(() => {
|
||||||
|
const sortFromUrl = searchParams.get('sort')
|
||||||
|
if (sortFromUrl) {
|
||||||
|
setSelectedSort(sortFromUrl)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const handleSortChange = (sortKey: string) => {
|
||||||
|
setSelectedSort(sortKey)
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('sort', sortKey)
|
||||||
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
@@ -47,7 +71,7 @@ const Sorts: FC = () => {
|
|||||||
<div
|
<div
|
||||||
key={option.key}
|
key={option.key}
|
||||||
className={`cursor-pointer ${selectedSort === option.key ? 'text-primary' : ''}`}
|
className={`cursor-pointer ${selectedSort === option.key ? 'text-primary' : ''}`}
|
||||||
onClick={() => setSelectedSort(option.key)}
|
onClick={() => handleSortChange(option.key)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +98,7 @@ const Sorts: FC = () => {
|
|||||||
className={`px-4 py-3 cursor-pointer text-sm hover:bg-gray-50 ${selectedSort === option.key ? 'text-primary bg-blue-50' : 'text-[#333333]'
|
className={`px-4 py-3 cursor-pointer text-sm hover:bg-gray-50 ${selectedSort === option.key ? 'text-primary bg-blue-50' : 'text-[#333333]'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSort(option.key)
|
handleSortChange(option.key)
|
||||||
setShowDropdown(false)
|
setShowDropdown(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -86,7 +110,7 @@ const Sorts: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='text-sm'>
|
<div className='text-sm'>
|
||||||
100 کالا
|
{data?.results?.pager?.totalItems || 0} کالا
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/Service";
|
||||||
|
import { ProductsResponse } from "@/types/products.types";
|
||||||
|
|
||||||
|
interface UseProductsDataParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
category?: string;
|
||||||
|
brand?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
attributes?: string[];
|
||||||
|
sizes?: number[];
|
||||||
|
meterages?: number[];
|
||||||
|
sort?: string;
|
||||||
|
wholeSale?: string;
|
||||||
|
stock?: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductsData = (params?: UseProductsDataParams) => {
|
||||||
|
return useQuery<ProductsResponse>({
|
||||||
|
queryKey: ["products", params],
|
||||||
|
queryFn: () => api.getProducts(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCategoryProductsData = (
|
||||||
|
categoryUrl: string,
|
||||||
|
params?: UseProductsDataParams
|
||||||
|
) => {
|
||||||
|
return useQuery<ProductsResponse>({
|
||||||
|
queryKey: ["category-products", categoryUrl, params],
|
||||||
|
queryFn: () => api.getCategoryProducts(categoryUrl, params),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import { ProductsResponse } from "@/types/products.types";
|
||||||
|
|
||||||
|
export const getProducts = async (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
category?: string;
|
||||||
|
brand?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
attributes?: string[];
|
||||||
|
sizes?: number[];
|
||||||
|
meterages?: number[];
|
||||||
|
sort?: string;
|
||||||
|
wholeSale?: string;
|
||||||
|
stock?: string;
|
||||||
|
rating?: number;
|
||||||
|
}): Promise<ProductsResponse> => {
|
||||||
|
const { data } = await axios.get("/category/Organic-supermarket/search", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoryProducts = async (
|
||||||
|
categoryUrl: string,
|
||||||
|
params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
brand?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
attributes?: string[];
|
||||||
|
sizes?: number[];
|
||||||
|
meterages?: number[];
|
||||||
|
sort?: string;
|
||||||
|
wholeSale?: string;
|
||||||
|
stock?: string;
|
||||||
|
category?: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
): Promise<ProductsResponse> => {
|
||||||
|
const { data } = await axios.get(`/category/${categoryUrl}/search`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -25,13 +25,37 @@ const ProductCard: FC<Props> = ({ item }) => {
|
|||||||
<h4 className='line-clamp-2 text-[#383E43] mt-4 sm:mt-6 text-xs sm:text-sm text-center leading-4 sm:leading-5 h-10 sm:h-12'>
|
<h4 className='line-clamp-2 text-[#383E43] mt-4 sm:mt-6 text-xs sm:text-sm text-center leading-4 sm:leading-5 h-10 sm:h-12'>
|
||||||
{item.title_fa}
|
{item.title_fa}
|
||||||
</h4>
|
</h4>
|
||||||
|
{/* <div className='flex flex-wrap justify-end items-center gap-1.5 mt-4'>
|
||||||
|
|
||||||
<div className='flex justify-end items-center gap-1.5 mt-4'>
|
{
|
||||||
<div className='size-2 rounded-full bg-red-500'></div>
|
item.variants.map((variant) => {
|
||||||
<div className='size-2 rounded-full bg-red-500'></div>
|
if (variant.meterage) {
|
||||||
<div className='size-2 rounded-full bg-red-500'></div>
|
return <div className='text-[10px] px-4 border border-border rounded-lg whitespace-nowrap' key={variant._id}>{variant?.meterage?.value}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap justify-end items-center gap-1.5 mt-4'>
|
||||||
|
{
|
||||||
|
item.variants.map((variant) => {
|
||||||
|
if (variant.color) {
|
||||||
|
return <div className='text-[10px] px-4 border border-border rounded-lg whitespace-nowrap' key={variant._id}>
|
||||||
|
<div className='size-2 rounded-full' style={{ backgroundColor: variant?.color?.value }}></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
item.variants.map((variant) => {
|
||||||
|
if (variant.size) {
|
||||||
|
return <div className='text-[10px] px-4 border border-border rounded-lg whitespace-nowrap' key={variant._id}>{variant?.size?.value}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div> */}
|
||||||
|
|
||||||
<div className='mt-4 sm:mt-5 text-xs sm:text-sm'>
|
<div className='mt-4 sm:mt-5 text-xs sm:text-sm'>
|
||||||
{NumberFormat(item.default_variant.price.selling_price)} تومان
|
{NumberFormat(item.default_variant.price.selling_price)} تومان
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -39,7 +39,7 @@ const Footer: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0'>
|
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0'>
|
||||||
<div className='text-[#383E43]'>
|
<div className='text-[#383E43] text-sm sm:text-base font-light'>
|
||||||
دانلود اپلیکیشن
|
دانلود اپلیکیشن
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 flex-wrap'>
|
<div className='flex gap-2 flex-wrap'>
|
||||||
@@ -61,7 +61,7 @@ const Footer: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0 mt-4 sm:mt-6'>
|
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0 mt-4 sm:mt-6'>
|
||||||
<div className='text-[#383E43] text-sm sm:text-base'>
|
<div className='text-[#383E43] text-sm sm:text-base font-light'>
|
||||||
با ثبت ایمیل از آخرین تخفیف ها با خبر شوید
|
با ثبت ایمیل از آخرین تخفیف ها با خبر شوید
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 w-full sm:w-auto'>
|
<div className='flex gap-2 w-full sm:w-auto'>
|
||||||
@@ -77,7 +77,7 @@ const Footer: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0 mt-4 sm:mt-6'>
|
<div className='w-full rounded-xl bg-[#F2F2F2] border border-border h-auto sm:h-[72px] flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:px-6 sm:py-0 gap-3 sm:gap-0 mt-4 sm:mt-6'>
|
||||||
<div className='text-[#383E43] text-sm sm:text-base'>
|
<div className='text-[#383E43] text-sm sm:text-base font-light'>
|
||||||
ما را در شبکه های اجتماعی دنبال کنید
|
ما را در شبکه های اجتماعی دنبال کنید
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,42 @@ export interface IDefaultVariant {
|
|||||||
isWholeSale: boolean;
|
isWholeSale: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IShipmentMethod {
|
||||||
|
_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
deliveryTime: number;
|
||||||
|
deliveryType: "Standard" | "SameDay" | "Express";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWarranty {
|
||||||
|
_id: number;
|
||||||
|
duration: string;
|
||||||
|
logoUrl: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMeterage {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVariant {
|
||||||
|
_id: string;
|
||||||
|
market_status: "Marketable" | "Unmarketable" | "OutOfStock";
|
||||||
|
price: IPrice;
|
||||||
|
stock: number;
|
||||||
|
postingTime: number;
|
||||||
|
isFreeShip: boolean;
|
||||||
|
isWholeSale: boolean;
|
||||||
|
shop: IShop;
|
||||||
|
shipmentMethod: IShipmentMethod[];
|
||||||
|
warranty: IWarranty;
|
||||||
|
meterage?: IMeterage;
|
||||||
|
size?: IMeterage;
|
||||||
|
color?: IMeterage;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IImagesUrl {
|
export interface IImagesUrl {
|
||||||
cover: string;
|
cover: string;
|
||||||
list: string[];
|
list: string[];
|
||||||
@@ -44,6 +80,7 @@ export interface IProduct {
|
|||||||
imagesUrl: IImagesUrl;
|
imagesUrl: IImagesUrl;
|
||||||
url: string;
|
url: string;
|
||||||
default_variant: IDefaultVariant;
|
default_variant: IDefaultVariant;
|
||||||
|
variants: IVariant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICategory {
|
export interface ICategory {
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
export interface Product {
|
||||||
|
_id: number;
|
||||||
|
url: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
tags: string[];
|
||||||
|
advantages: string[];
|
||||||
|
disAdvantages: string[];
|
||||||
|
imagesUrl: {
|
||||||
|
cover: string;
|
||||||
|
list: string[];
|
||||||
|
};
|
||||||
|
isFake: string;
|
||||||
|
isWished: boolean | null;
|
||||||
|
specifications: Array<{
|
||||||
|
title: string;
|
||||||
|
values: string[];
|
||||||
|
}>;
|
||||||
|
brand: {
|
||||||
|
_id: string;
|
||||||
|
status: string;
|
||||||
|
title_en: string;
|
||||||
|
title_fa: string;
|
||||||
|
images: string[];
|
||||||
|
logoUrl: string;
|
||||||
|
};
|
||||||
|
category: {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
theme: string;
|
||||||
|
description: string;
|
||||||
|
leaf: boolean;
|
||||||
|
parent: string;
|
||||||
|
};
|
||||||
|
default_variant?: ProductVariant;
|
||||||
|
variants: ProductVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductVariant {
|
||||||
|
_id: string;
|
||||||
|
market_status: string;
|
||||||
|
price: {
|
||||||
|
order_limit: number;
|
||||||
|
retailPrice: number;
|
||||||
|
selling_price: number;
|
||||||
|
is_specialSale: boolean;
|
||||||
|
discount_percent: number;
|
||||||
|
specialSale_order_limit: number | null;
|
||||||
|
specialSale_quantity: number | null;
|
||||||
|
specialSale_endDate: string | null;
|
||||||
|
};
|
||||||
|
stock: number;
|
||||||
|
postingTime: number;
|
||||||
|
isFreeShip: boolean;
|
||||||
|
isWholeSale: boolean;
|
||||||
|
shop: {
|
||||||
|
_id: string;
|
||||||
|
shopName: string;
|
||||||
|
shopCode: number;
|
||||||
|
owner: string;
|
||||||
|
shopDescription: string;
|
||||||
|
telephoneNumber: string;
|
||||||
|
isChatActive: boolean;
|
||||||
|
shopPostalCode: string;
|
||||||
|
logo: string;
|
||||||
|
};
|
||||||
|
shipmentMethod: Array<{
|
||||||
|
_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
deliveryTime: number;
|
||||||
|
deliveryType: string;
|
||||||
|
}>;
|
||||||
|
warranty: {
|
||||||
|
_id: number;
|
||||||
|
duration: string;
|
||||||
|
logoUrl: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
meterage?: {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
size?: {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
description: string;
|
||||||
|
variants: number[];
|
||||||
|
hierarchy: string[];
|
||||||
|
leaf: boolean;
|
||||||
|
parent: string;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
theme?: string;
|
||||||
|
url: string;
|
||||||
|
children: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Brand {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
logoUrl: string;
|
||||||
|
images: string[];
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterAttribute {
|
||||||
|
_id: number;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
type: string;
|
||||||
|
multiple: boolean;
|
||||||
|
required: boolean;
|
||||||
|
values: Array<{
|
||||||
|
_id: number;
|
||||||
|
attribute: number;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
__v: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceRange {
|
||||||
|
minPrice: number;
|
||||||
|
maxPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meterage {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description: string;
|
||||||
|
leaf: boolean;
|
||||||
|
parent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pager {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
prevPage: boolean;
|
||||||
|
nextPage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsResponse {
|
||||||
|
status: number;
|
||||||
|
success: boolean;
|
||||||
|
results: {
|
||||||
|
category: Category;
|
||||||
|
brands: Brand[];
|
||||||
|
products: Product[];
|
||||||
|
filters: {
|
||||||
|
attributes: FilterAttribute[];
|
||||||
|
priceRange: PriceRange;
|
||||||
|
sizes: Size[];
|
||||||
|
meterages: Meterage[];
|
||||||
|
};
|
||||||
|
breadcrumb: Breadcrumb[];
|
||||||
|
pager: Pager;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user