api attachment product list + filter

This commit is contained in:
hamid zarghami
2025-09-04 12:44:40 +03:30
parent 40004b50b3
commit 477b42cd31
14 changed files with 952 additions and 128 deletions
+46
View File
@@ -12,6 +12,7 @@
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.85.5",
@@ -999,6 +1000,12 @@
"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": {
"version": "1.1.2",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.85.5",
+135 -17
View File
@@ -12,13 +12,103 @@ import {
} from '@/components/ui/breadcrumb'
import Filters from '../components/Filters'
import Sorts from '../components/Sorts'
import Pagination from '../components/Pagination'
import GridWrapper from '@/components/GridWrapper'
import ProductCard from '@/components/ProductCard'
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 [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 (
<div className="mt-14 px-4 sm:px-6 md:px-8 lg:px-20">
@@ -27,14 +117,22 @@ const Products: NextPage = () => {
<BreadcrumbItem>
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href="/products">بهداشت خانگی</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground">مواد شوینده</BreadcrumbPage>
</BreadcrumbItem>
{data?.results.breadcrumb?.map((item, index) => (
<div key={item._id} className="flex items-center">
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
{index === data.results.breadcrumb.length - 1 ? (
<BreadcrumbPage className="text-foreground">
{item.title_fa}
</BreadcrumbPage>
) : (
<BreadcrumbLink href={`/category/${item.title_en}`}>
{item.title_fa}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</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'>
{/* Desktop Filters */}
<Filters />
<Filters categoryUrl={categoryUrl} />
{/* Mobile Filters Modal */}
<Filters
isMobile={true}
isOpen={showMobileFilters}
onClose={() => setShowMobileFilters(false)}
categoryUrl={categoryUrl}
/>
<div className='flex-1'>
<Sorts />
<Sorts data={data} />
<div className='mt-6 sm:mt-8 md:mt-10'>
<GridWrapper desktop={4} mobile={2} gapMobile={3} gapDesktop={6}>
<ProductCard />
<ProductCard />
<ProductCard />
<ProductCard />
</GridWrapper>
{isLoading ? (
<div className="text-center py-8">
<div className="text-gray-500">در حال بارگذاری...</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>
+42 -13
View File
@@ -2,27 +2,53 @@
import { FC, useCallback, useMemo, useState } from '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 = {
categories: Category[]
selectedId: string | null
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 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 (
<div className='space-y-0'>
<Row
label='همه کالا ها'
isLeaf
isSelected={selectedId === null}
onClick={() => onSelect(null)}
/>
{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>
)
@@ -40,8 +66,11 @@ const Node: FC<NodeProps> = ({ node, selectedId, onSelect, depth }) => {
const hasChildren = (node.children?.length ?? 0) > 0
const toggle = useCallback(() => {
if (hasChildren) setOpen((p) => !p)
else onSelect(node._id)
if (hasChildren) {
setOpen((p) => !p)
} else {
onSelect(node._id)
}
}, [hasChildren, node._id, onSelect])
return (
@@ -63,8 +92,8 @@ const Node: FC<NodeProps> = ({ node, selectedId, onSelect, depth }) => {
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>)
)}
/>
))}
</div>
)}
</div>
+162 -79
View File
@@ -1,93 +1,116 @@
"use client"
import { FC, useMemo, useState, useEffect } from 'react'
import { FC, useMemo, useState, useEffect, useCallback, useRef } from 'react'
import { Setting5 } from 'iconsax-react'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import Input from '@/components/Input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { useQuery } from '@tanstack/react-query'
import { getCategories } from '@/share/service/ShareService'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import CategoryTree from './CategoryTree'
import { Category } from '@/share/types/SharedTypes'
import Modal from '@/components/Modal'
import * as api from '../service/Service'
import { Brand, Category } from '@/types/products.types'
type FiltersProps = {
isMobile?: boolean
isOpen?: boolean
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 pathname = usePathname()
const searchParams = useSearchParams()
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [minPrice, setMinPrice] = useState<string>('')
const [maxPrice, setMaxPrice] = useState<string>('')
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000000])
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({
queryKey: ['categories'],
queryFn: getCategories,
staleTime: 1000 * 60 * 10,
// 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 categoriesData?.results?.data ?? []
}, [categoriesData])
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') ?? ''
const qsMax = searchParams.get('maxPrice') ?? ''
const qsStock = searchParams.get('inStock') === '1'
const qsRating = searchParams.get('rating')
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)
setMinPrice(qsMin)
setMaxPrice(qsMax)
setPriceRange([qsMin, qsMax])
setInStockOnly(qsStock)
setRating(qsRating ? Number(qsRating) : null)
setSelectedBrand(qsBrand)
setWholeSale(qsWholeSale)
// 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())
if (selectedCategories.length > 0) {
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')
;['category', 'minPrice', 'maxPrice', 'stock', 'brand', 'wholeSale'].forEach((k) => params.delete(k))
router.push(`${pathname}?${params.toString()}`)
// Close mobile modal after applying filters
// Close mobile modal after clearing filters
if (isMobile && 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 = () => (
<>
@@ -104,7 +127,19 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
<CategoryTree
categories={categories}
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>
</AccordionItem>
@@ -112,23 +147,24 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
<AccordionItem value='price'>
<AccordionTrigger className='text-[#333333]'>قیمت</AccordionTrigger>
<AccordionContent>
<div className='grid grid-cols-2 gap-2.5'>
<Input
label='حداقل'
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder='0'
seprator
inputMode='numeric'
/>
<Input
label='حداکثر'
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder='0'
seprator
inputMode='numeric'
/>
<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>
@@ -141,45 +177,92 @@ const Filters: FC<FiltersProps> = ({ isMobile = false, isOpen = false, onClose }
type='checkbox'
className='size-4 rounded border-border'
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>
</AccordionContent>
</AccordionItem>
<AccordionItem value='rating'>
<AccordionTrigger className='text-[#333333]'>امتیاز خریداران</AccordionTrigger>
<AccordionItem value='brand'>
<AccordionTrigger className='text-[#333333]'>برند</AccordionTrigger>
<AccordionContent>
<div className='flex flex-col gap-2.5 text-sm text-[#4A4A4A]'>
{[4, 3, 2].map((r) => (
<label key={r} className='flex items-center gap-2.5'>
<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='rating'
name='brand'
className='size-4 border-border'
checked={rating === r}
onChange={() => setRating(r)}
checked={selectedBrand === brand._id}
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>
))}
<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'
>
حذف امتیاز
حذف برند
</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>
<div className='mt-4 grid grid-cols-2 gap-2.5'>
<Button variant='outline' onClick={clearFilters}>حذف</Button>
<Button onClick={applyFilters}>اعمال</Button>
<div className='mt-4'>
<Button variant='outline' onClick={clearFilters} className='w-full'>حذف همه فیلترها</Button>
</div>
</>
)
+116
View File
@@ -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
+36 -12
View File
@@ -1,23 +1,47 @@
import { Sort, ArrowDown2 } from 'iconsax-react'
import { FC, useState, useEffect, useRef } from 'react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { ProductsResponse } from '@/types/products.types'
const sortOptions = [
{ key: 'relevant', label: 'مرتبط ترین' },
{ key: 'most_visited', label: 'پربازدیدترین' },
{ key: 'newest', label: 'جدیدترین' },
{ key: 'best_selling', label: 'پرفروش ترین' },
{ key: 'cheapest', label: 'ارزانترین' },
{ key: 'most_expensive', label: 'گرانترین' },
{ key: 'customer_suggestion', label: 'پیشنهاد خریداران' },
{ key: '1', label: 'مرتبط ترین' },
{ key: '2', label: 'پربازدیدترین' },
{ key: '3', label: 'جدیدترین' },
{ key: '4', label: 'پرفروش ترین' },
{ key: '5', label: 'ارزانترین' },
{ key: '6', label: 'گرانترین' },
{ key: '7', label: 'پیشنهاد خریداران' },
]
const Sorts: FC = () => {
const [selectedSort, setSelectedSort] = useState('newest')
type SortsProps = {
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 dropdownRef = useRef<HTMLDivElement>(null)
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(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -47,7 +71,7 @@ const Sorts: FC = () => {
<div
key={option.key}
className={`cursor-pointer ${selectedSort === option.key ? 'text-primary' : ''}`}
onClick={() => setSelectedSort(option.key)}
onClick={() => handleSortChange(option.key)}
>
{option.label}
</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]'
}`}
onClick={() => {
setSelectedSort(option.key)
handleSortChange(option.key)
setShowDropdown(false)
}}
>
@@ -86,7 +110,7 @@ const Sorts: FC = () => {
</div>
<div className='text-sm'>
100 کالا
{data?.results?.pager?.totalItems || 0} کالا
</div>
</div>
)
+36
View File
@@ -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),
});
};
+47
View File
@@ -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;
};
+28 -4
View File
@@ -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'>
{item.title_fa}
</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>
<div className='size-2 rounded-full bg-red-500'></div>
<div className='size-2 rounded-full bg-red-500'></div>
{
item.variants.map((variant) => {
if (variant.meterage) {
return <div className='text-[10px] px-4 border border-border rounded-lg whitespace-nowrap' key={variant._id}>{variant?.meterage?.value}</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'>
{NumberFormat(item.default_variant.price.selling_price)} تومان
</div>
+63
View File
@@ -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 }
+3 -3
View File
@@ -39,7 +39,7 @@ const Footer: FC = () => {
</div>
<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='text-[#383E43]'>
<div className='text-[#383E43] text-sm sm:text-base font-light'>
دانلود اپلیکیشن
</div>
<div className='flex gap-2 flex-wrap'>
@@ -61,7 +61,7 @@ const Footer: FC = () => {
</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='text-[#383E43] text-sm sm:text-base'>
<div className='text-[#383E43] text-sm sm:text-base font-light'>
با ثبت ایمیل از آخرین تخفیف ها با خبر شوید
</div>
<div className='flex gap-2 w-full sm:w-auto'>
@@ -77,7 +77,7 @@ const Footer: FC = () => {
</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='text-[#383E43] text-sm sm:text-base'>
<div className='text-[#383E43] text-sm sm:text-base font-light'>
ما را در شبکه های اجتماعی دنبال کنید
</div>
+37
View File
@@ -25,6 +25,42 @@ export interface IDefaultVariant {
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 {
cover: string;
list: string[];
@@ -44,6 +80,7 @@ export interface IProduct {
imagesUrl: IImagesUrl;
url: string;
default_variant: IDefaultVariant;
variants: IVariant[];
}
export interface ICategory {
+200
View File
@@ -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;
};
}