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-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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 { 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>
|
||||
)
|
||||
|
||||
@@ -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'>
|
||||
{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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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