brand page
This commit is contained in:
@@ -0,0 +1,229 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Layout from '@/hoc/Layout'
|
||||||
|
import { NextPage } from 'next'
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@/components/ui/breadcrumb'
|
||||||
|
import BrandIntro from '../components/BrandIntro'
|
||||||
|
import BrandFilters from '../components/BrandFilters'
|
||||||
|
import BrandSorts from '../components/BrandSorts'
|
||||||
|
import GridWrapper from '@/components/GridWrapper'
|
||||||
|
import ProductCard from '@/components/ProductCard'
|
||||||
|
import { Setting5 } from 'iconsax-react'
|
||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||||
|
import { useInfiniteBrandProductsData } from '../hooks/useInfiniteBrandData'
|
||||||
|
import { IProduct } from '@/types/landing.types'
|
||||||
|
import { Product } from '../types/Types'
|
||||||
|
import { useSearchParams, useParams } from 'next/navigation'
|
||||||
|
|
||||||
|
const BrandPage: NextPage = () => {
|
||||||
|
const [showMobileFilters, setShowMobileFilters] = useState(false)
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const params = useParams()
|
||||||
|
const [filterParams, setFilterParams] = useState({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sort: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// استخراج brandId از URL
|
||||||
|
const brandId = params.id as string
|
||||||
|
|
||||||
|
// خواندن پارامترها از URL
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = {
|
||||||
|
page: parseInt(searchParams.get('page') || '1'),
|
||||||
|
limit: parseInt(searchParams.get('limit') || '20'),
|
||||||
|
sort: parseInt(searchParams.get('sort') || '3'),
|
||||||
|
category: searchParams.get('category') || undefined,
|
||||||
|
minPrice: searchParams.get('minPrice') ? Number(searchParams.get('minPrice')) : undefined,
|
||||||
|
maxPrice: searchParams.get('maxPrice') ? Number(searchParams.get('maxPrice')) : undefined,
|
||||||
|
}
|
||||||
|
setFilterParams(urlParams)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// دریافت دادههای محصولات از API بر اساس brandId با infinite scroll
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage
|
||||||
|
} = useInfiniteBrandProductsData(brandId, filterParams)
|
||||||
|
|
||||||
|
// جمعآوری تمام محصولات از صفحات مختلف
|
||||||
|
const allProducts = useMemo(() => {
|
||||||
|
if (!data?.pages) return [];
|
||||||
|
return data.pages.flatMap(page => page.results.products);
|
||||||
|
}, [data?.pages]);
|
||||||
|
|
||||||
|
// تبدیل 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,
|
||||||
|
isWishlist: false,
|
||||||
|
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-1.5">
|
||||||
|
{/* معرفی برند */}
|
||||||
|
{data?.pages?.[0]?.results.brand && (
|
||||||
|
<BrandIntro brand={data.pages[0].results.brand} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className=' px-4 sm:px-6 md:px-8 lg:px-20'>
|
||||||
|
<Breadcrumb aria-label="breadcrumb">
|
||||||
|
<BreadcrumbList className="text-sm text-muted-foreground">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{data?.pages?.[0]?.results.brand && (
|
||||||
|
<>
|
||||||
|
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage className="text-foreground">
|
||||||
|
{data.pages[0].results.brand.title_fa}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
{/* Mobile Filter Button */}
|
||||||
|
<div className='lg:hidden mt-4 flex justify-between items-center'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMobileFilters(true)}
|
||||||
|
className='flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-white shadow-sm'
|
||||||
|
>
|
||||||
|
<Setting5 size={18} color='#666666' />
|
||||||
|
<span className='text-sm text-[#666666]'>فیلترها</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 */}
|
||||||
|
<BrandFilters brandId={brandId} />
|
||||||
|
|
||||||
|
{/* Mobile Filters Modal */}
|
||||||
|
<BrandFilters
|
||||||
|
isMobile={true}
|
||||||
|
isOpen={showMobileFilters}
|
||||||
|
onClose={() => setShowMobileFilters(false)}
|
||||||
|
brandId={brandId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='flex-1'>
|
||||||
|
<BrandSorts data={data?.pages?.[0]} />
|
||||||
|
|
||||||
|
<div className='mt-6 sm:mt-8 md:mt-10'>
|
||||||
|
{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>
|
||||||
|
) : allProducts.length > 0 ? (
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={allProducts.length}
|
||||||
|
next={fetchNextPage}
|
||||||
|
hasMore={!!hasNextPage}
|
||||||
|
loader={
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="text-gray-500">در حال بارگذاری محصولات بیشتر...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
endMessage={
|
||||||
|
<div className="text-center py-4">
|
||||||
|
{/* <div className="text-gray-500">تمام محصولات بارگذاری شد</div> */}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GridWrapper desktop={4} mobile={2} gapMobile={3} gapDesktop={6}>
|
||||||
|
{allProducts.map((product: Product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product._id}
|
||||||
|
item={convertToIProduct(product)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</GridWrapper>
|
||||||
|
</InfiniteScroll>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-gray-500">محصولی یافت نشد</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrandPageWithLayout() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<BrandPage />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC, useState, useCallback } from 'react'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import { CategoryVariant } from '../types/Types'
|
||||||
|
import { NumberFormat } from '@/config/func'
|
||||||
|
import CategoryTree from '@/app/products/components/CategoryTree'
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
categories: string[]
|
||||||
|
priceRange: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrandFilterSectionProps {
|
||||||
|
categories: CategoryVariant[]
|
||||||
|
filterState: FilterState
|
||||||
|
onCategoryChange: (categoryId: string) => void
|
||||||
|
onPriceChange: (range: [number, number]) => void
|
||||||
|
brandId: string
|
||||||
|
priceMinMaxRange: { minPrice: number; maxPrice: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandFilterSection: FC<BrandFilterSectionProps> = ({
|
||||||
|
categories,
|
||||||
|
filterState,
|
||||||
|
onCategoryChange,
|
||||||
|
onPriceChange,
|
||||||
|
priceMinMaxRange,
|
||||||
|
}) => {
|
||||||
|
const [priceInputs, setPriceInputs] = useState({
|
||||||
|
min: filterState.priceRange?.[0] || priceMinMaxRange.minPrice,
|
||||||
|
max: filterState.priceRange?.[1] || priceMinMaxRange.maxPrice,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePriceInputChange = (field: 'min' | 'max', value: string) => {
|
||||||
|
const numValue = parseInt(value.replace(/,/g, '')) || 0
|
||||||
|
setPriceInputs(prev => ({ ...prev, [field]: numValue }))
|
||||||
|
|
||||||
|
if (field === 'min') {
|
||||||
|
onPriceChange([numValue, priceInputs.max])
|
||||||
|
} else {
|
||||||
|
onPriceChange([priceInputs.min, numValue])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* دستهبندیها */}
|
||||||
|
<Accordion type='single' collapsible className='w-full'>
|
||||||
|
<AccordionItem value='categories'>
|
||||||
|
<AccordionTrigger className='text-sm font-medium'>
|
||||||
|
دستهبندیها
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className='mt-4'>
|
||||||
|
<CategoryTree
|
||||||
|
categories={categories as any}
|
||||||
|
selectedId={filterState.categories[0] || null}
|
||||||
|
onSelect={(id) => id && onCategoryChange(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* محدوده قیمت */}
|
||||||
|
<AccordionItem value='price'>
|
||||||
|
<AccordionTrigger className='text-sm font-medium'>
|
||||||
|
محدوده قیمت
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className='mt-4 space-y-4'>
|
||||||
|
{/* ورودیهای قیمت */}
|
||||||
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-gray-600 mb-1'>از</label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={NumberFormat(priceInputs.min)}
|
||||||
|
onChange={(e) => handlePriceInputChange('min', e.target.value)}
|
||||||
|
className='text-sm'
|
||||||
|
placeholder='0'
|
||||||
|
seprator={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-gray-600 mb-1'>تا</label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={NumberFormat(priceInputs.max)}
|
||||||
|
onChange={(e) => handlePriceInputChange('max', e.target.value)}
|
||||||
|
className='text-sm'
|
||||||
|
placeholder='8,000,000'
|
||||||
|
seprator={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اسلایدر قیمت */}
|
||||||
|
<div className='px-2'>
|
||||||
|
<Slider
|
||||||
|
value={[priceInputs.min, priceInputs.max]}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPriceInputs({ min: value[0], max: value[1] })
|
||||||
|
onPriceChange([value[0], value[1]])
|
||||||
|
}}
|
||||||
|
min={priceMinMaxRange.minPrice}
|
||||||
|
max={priceMinMaxRange.maxPrice}
|
||||||
|
step={100000}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* واحد قیمت */}
|
||||||
|
<div className='text-center'>
|
||||||
|
<span className='text-xs text-gray-500'>تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default BrandFilterSection
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { Setting5 } from 'iconsax-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import BrandFilterSection from './BrandFilterSection'
|
||||||
|
import { useBrandFiltersData } from '../hooks/useBrandFiltersData'
|
||||||
|
|
||||||
|
interface BrandFiltersProps {
|
||||||
|
isMobile?: boolean
|
||||||
|
isOpen?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
brandId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandFilters: FC<BrandFiltersProps> = ({
|
||||||
|
isMobile = false,
|
||||||
|
isOpen = false,
|
||||||
|
onClose,
|
||||||
|
brandId
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
filterState,
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
priceMinMaxRange,
|
||||||
|
applyCategoryFilter,
|
||||||
|
updatePriceRange,
|
||||||
|
clearAllFilters,
|
||||||
|
} = useBrandFiltersData({ brandId })
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
clearAllFilters()
|
||||||
|
|
||||||
|
// Close mobile modal after clearing filters
|
||||||
|
if (isMobile && onClose) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterContent = () => (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center gap-2.5'>
|
||||||
|
<Setting5 size={24} color='#666666' />
|
||||||
|
<div className='text-[#666666]'>فیلترها</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrandFilterSection
|
||||||
|
categories={categories}
|
||||||
|
filterState={filterState}
|
||||||
|
onCategoryChange={applyCategoryFilter}
|
||||||
|
onPriceChange={updatePriceRange}
|
||||||
|
brandId={brandId}
|
||||||
|
priceMinMaxRange={priceMinMaxRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-4'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className='w-full'
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
حذف همه فیلترها
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose || (() => { })}
|
||||||
|
title="فیلترها"
|
||||||
|
isHeader={true}
|
||||||
|
>
|
||||||
|
<div className='max-h-[70vh] overflow-y-auto'>
|
||||||
|
<FilterContent />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-[270px] hidden lg:block border border-border rounded-[10px] p-5 h-fit'>
|
||||||
|
<FilterContent />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandFilters
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Brand } from '../types/Types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
brand: Brand
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandIntro: FC<Props> = ({ brand }) => {
|
||||||
|
return (
|
||||||
|
<div className='bg-[#FAFAFA] rounded-2xl p-6 px-24 mb-8 flex justify-between'>
|
||||||
|
<div className='flex gap-10'>
|
||||||
|
<div className='bg-[#F2F2F2] size-[118px] rounded-xl flex items-center justify-center'>
|
||||||
|
<Image
|
||||||
|
src={brand.logoUrl}
|
||||||
|
width={112}
|
||||||
|
height={112}
|
||||||
|
alt={brand.title_fa}
|
||||||
|
className='object-contain max-w-[100px] max-h-[100px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className='text-[#333333] text-xl '>
|
||||||
|
{brand.title_en?.toUpperCase()}
|
||||||
|
</h1>
|
||||||
|
<h4 className='text-[#999999] text-sm mt-2'>
|
||||||
|
{brand.title_fa}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className='mt-7 text-[#333333] text-sm'>
|
||||||
|
{brand.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandIntro
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
import { Pagination, Navigation } from 'swiper/modules'
|
||||||
|
import ProductCard from '@/components/ProductCard'
|
||||||
|
import { Product } from '../types/Types'
|
||||||
|
import { IProduct } from '@/types/landing.types'
|
||||||
|
import 'swiper/css'
|
||||||
|
import 'swiper/css/pagination'
|
||||||
|
import 'swiper/css/navigation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: Product[]
|
||||||
|
brandName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandProductsCarousel: FC<Props> = ({ products, brandName }) => {
|
||||||
|
return (
|
||||||
|
<div className='bg-white rounded-2xl p-6 shadow-sm'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-bold text-gray-800'>محصولات برند {brandName}</h3>
|
||||||
|
<div className='flex items-center gap-2 text-sm text-gray-500'>
|
||||||
|
<span>نمایش محصولات</span>
|
||||||
|
<div className='w-2 h-2 bg-blue-500 rounded-full'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Swiper
|
||||||
|
modules={[Pagination, Navigation]}
|
||||||
|
spaceBetween={16}
|
||||||
|
slidesPerView='auto'
|
||||||
|
pagination={{
|
||||||
|
clickable: true,
|
||||||
|
dynamicBullets: true,
|
||||||
|
}}
|
||||||
|
navigation={{
|
||||||
|
nextEl: '.swiper-button-next',
|
||||||
|
prevEl: '.swiper-button-prev',
|
||||||
|
}}
|
||||||
|
breakpoints={{
|
||||||
|
640: {
|
||||||
|
spaceBetween: 20,
|
||||||
|
},
|
||||||
|
768: {
|
||||||
|
spaceBetween: 24,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className='brand-products-swiper'
|
||||||
|
>
|
||||||
|
{products.map((product) => {
|
||||||
|
// تبدیل Product به IProduct
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const iProduct: IProduct = {
|
||||||
|
_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,
|
||||||
|
isWishlist: product.isWished || false,
|
||||||
|
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 (
|
||||||
|
<SwiperSlide key={product._id} className='!w-auto'>
|
||||||
|
<div className='w-[240px] sm:w-[280px]'>
|
||||||
|
<ProductCard item={iProduct} />
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.brand-products-swiper .swiper-pagination {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-products-swiper .swiper-pagination-bullet {
|
||||||
|
background: #d1d5db;
|
||||||
|
opacity: 1;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-products-swiper .swiper-pagination-bullet-active {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-products-swiper .swiper-button-next,
|
||||||
|
.brand-products-swiper .swiper-button-prev {
|
||||||
|
color: #3b82f6;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-top: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-products-swiper .swiper-button-next:after,
|
||||||
|
.brand-products-swiper .swiper-button-prev:after {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandProductsCarousel
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Sort, ArrowDown2 } from 'iconsax-react'
|
||||||
|
import { FC, useState, useEffect, useRef } from 'react'
|
||||||
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
import { BrandResponse } from '../types/Types'
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ key: '1', label: 'مرتبط ترین' },
|
||||||
|
{ key: '2', label: 'پربازدیدترین' },
|
||||||
|
{ key: '3', label: 'جدیدترین' },
|
||||||
|
{ key: '4', label: 'پرفروش ترین' },
|
||||||
|
{ key: '5', label: 'ارزانترین' },
|
||||||
|
{ key: '6', label: 'گرانترین' },
|
||||||
|
{ key: '7', label: 'پیشنهاد خریداران' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type BrandSortsProps = {
|
||||||
|
data?: BrandResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandSorts: FC<BrandSortsProps> = ({ 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)) {
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showDropdown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex justify-between items-center text-[#999999] text-sm border-b border-border pb-5'>
|
||||||
|
{/* Desktop Version */}
|
||||||
|
<div className='hidden lg:flex items-center gap-4'>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<Sort size={24} color='#333333' />
|
||||||
|
<div className='text-sm text-[#333333]'>مرتب سازی:</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.key}
|
||||||
|
className={`cursor-pointer ${selectedSort === option.key ? 'text-primary' : ''}`}
|
||||||
|
onClick={() => handleSortChange(option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Version */}
|
||||||
|
<div className='lg:hidden flex items-center gap-2 relative' ref={dropdownRef}>
|
||||||
|
<Sort size={20} color='#333333' />
|
||||||
|
<div className='text-sm text-[#333333]'>مرتب سازی:</div>
|
||||||
|
<div
|
||||||
|
className='flex items-center gap-1 cursor-pointer bg-gray-50 px-3 py-1.5 rounded-lg'
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
>
|
||||||
|
<span className='text-sm text-[#333333]'>{selectedOption?.label}</span>
|
||||||
|
<ArrowDown2 size={16} color='#333333' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className='absolute top-full right-0 mt-2 bg-white border border-border rounded-lg shadow-lg z-10 min-w-[180px]'>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.key}
|
||||||
|
className={`px-4 py-3 cursor-pointer text-sm hover:bg-gray-50 ${selectedSort === option.key ? 'text-primary bg-blue-50' : 'text-[#333333]'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleSortChange(option.key)
|
||||||
|
setShowDropdown(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='text-sm'>
|
||||||
|
{data?.results?.pager?.totalItems || 0} کالا
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandSorts
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { ArrowLeft2, ArrowRight2 } from 'iconsax-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pagination: FC<Props> = ({ currentPage, totalPages, onPageChange }) => {
|
||||||
|
const getVisiblePages = () => {
|
||||||
|
const delta = 2
|
||||||
|
const range = []
|
||||||
|
const rangeWithDots = []
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = Math.max(2, currentPage - delta);
|
||||||
|
i <= Math.min(totalPages - 1, currentPage + delta);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
range.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage - delta > 2) {
|
||||||
|
rangeWithDots.push(1, '...')
|
||||||
|
} else {
|
||||||
|
rangeWithDots.push(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeWithDots.push(...range)
|
||||||
|
|
||||||
|
if (currentPage + delta < totalPages - 1) {
|
||||||
|
rangeWithDots.push('...', totalPages)
|
||||||
|
} else if (totalPages > 1) {
|
||||||
|
rangeWithDots.push(totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rangeWithDots
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePages = getVisiblePages()
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-center gap-2 mt-8'>
|
||||||
|
{/* دکمه قبلی */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className='flex items-center justify-center w-10 h-10 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
||||||
|
>
|
||||||
|
<ArrowRight2 size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* شماره صفحات */}
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{visiblePages.map((page, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
{page === '...' ? (
|
||||||
|
<span className='px-3 py-2 text-gray-500'>...</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page as number)}
|
||||||
|
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${currentPage === page
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه بعدی */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className='flex items-center justify-center w-10 h-10 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
||||||
|
>
|
||||||
|
<ArrowLeft2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC, useState } from 'react'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import { CategoryVariant } from '../types/Types'
|
||||||
|
import { NumberFormat } from '@/config/func'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
categories: CategoryVariant[]
|
||||||
|
priceRange: { minPrice: number; maxPrice: number }
|
||||||
|
selectedCategories: string[]
|
||||||
|
selectedPriceRange: [number, number]
|
||||||
|
onCategoryChange: (categoryId: string) => void
|
||||||
|
onPriceRangeChange: (range: [number, number]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductFilters: FC<Props> = ({
|
||||||
|
categories,
|
||||||
|
priceRange,
|
||||||
|
selectedCategories,
|
||||||
|
selectedPriceRange,
|
||||||
|
onCategoryChange,
|
||||||
|
onPriceRangeChange,
|
||||||
|
}) => {
|
||||||
|
const [priceInputs, setPriceInputs] = useState({
|
||||||
|
min: selectedPriceRange[0],
|
||||||
|
max: selectedPriceRange[1],
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePriceInputChange = (field: 'min' | 'max', value: string) => {
|
||||||
|
const numValue = parseInt(value.replace(/,/g, '')) || 0
|
||||||
|
setPriceInputs(prev => ({ ...prev, [field]: numValue }))
|
||||||
|
|
||||||
|
if (field === 'min') {
|
||||||
|
onPriceRangeChange([numValue, selectedPriceRange[1]])
|
||||||
|
} else {
|
||||||
|
onPriceRangeChange([selectedPriceRange[0], numValue])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-white rounded-2xl p-6 shadow-sm sticky top-4'>
|
||||||
|
<h3 className='text-lg font-bold text-gray-800 mb-6'>فیلترها</h3>
|
||||||
|
|
||||||
|
{/* دستهبندیها */}
|
||||||
|
<Accordion type='single' collapsible className='w-full'>
|
||||||
|
<AccordionItem value='categories'>
|
||||||
|
<AccordionTrigger className='text-sm font-medium'>
|
||||||
|
دستهبندیها
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className='space-y-3 mt-4'>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<label
|
||||||
|
key={category._id}
|
||||||
|
className='flex items-center gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={selectedCategories.includes(category._id)}
|
||||||
|
onChange={() => onCategoryChange(category._id)}
|
||||||
|
className='w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500'
|
||||||
|
/>
|
||||||
|
<span className='text-sm text-gray-700 flex-1'>{category.title_fa}</span>
|
||||||
|
<span className='text-xs text-gray-500'>({category.variants.length})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* محدوده قیمت */}
|
||||||
|
<AccordionItem value='price'>
|
||||||
|
<AccordionTrigger className='text-sm font-medium'>
|
||||||
|
محدوده قیمت
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className='mt-4 space-y-4'>
|
||||||
|
{/* ورودیهای قیمت */}
|
||||||
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-gray-600 mb-1'>از</label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={NumberFormat(priceInputs.min)}
|
||||||
|
onChange={(e) => handlePriceInputChange('min', e.target.value)}
|
||||||
|
className='text-sm'
|
||||||
|
placeholder='0'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-gray-600 mb-1'>تا</label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={NumberFormat(priceInputs.max)}
|
||||||
|
onChange={(e) => handlePriceInputChange('max', e.target.value)}
|
||||||
|
className='text-sm'
|
||||||
|
placeholder='8,000,000'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اسلایدر قیمت */}
|
||||||
|
<div className='px-2'>
|
||||||
|
<Slider
|
||||||
|
value={selectedPriceRange}
|
||||||
|
onValueChange={onPriceRangeChange}
|
||||||
|
min={priceRange.minPrice}
|
||||||
|
max={priceRange.maxPrice}
|
||||||
|
step={100000}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* واحد قیمت */}
|
||||||
|
<div className='text-center'>
|
||||||
|
<span className='text-xs text-gray-500'>تومان</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductFilters
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import ProductCard from '@/components/ProductCard'
|
||||||
|
import { Product } from '../types/Types'
|
||||||
|
import { IProduct } from '@/types/landing.types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: Product[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductGrid: FC<Props> = ({ products, isLoading = false }) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800'>محصولات تولید کننده</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'>
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={index} className='animate-pulse'>
|
||||||
|
<div className='bg-gray-200 rounded-2xl p-6 h-[320px]'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800'>محصولات تولید کننده</h2>
|
||||||
|
<div className='text-sm text-gray-500'>
|
||||||
|
{products.length} محصول یافت شد
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className='text-center py-12'>
|
||||||
|
<div className='text-gray-400 text-lg mb-2'>هیچ محصولی یافت نشد</div>
|
||||||
|
<div className='text-gray-500 text-sm'>لطفاً فیلترهای خود را تغییر دهید</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'>
|
||||||
|
{products.map((product) => {
|
||||||
|
// تبدیل Product به IProduct
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const iProduct: IProduct = {
|
||||||
|
_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,
|
||||||
|
isWishlist: product.isWished || false,
|
||||||
|
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 (
|
||||||
|
<ProductCard
|
||||||
|
key={product._id}
|
||||||
|
item={iProduct}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductGrid
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/Service";
|
||||||
|
import { BrandProductsParams } from "../types/Types";
|
||||||
|
|
||||||
|
export const useGetBrandProducts = (params: BrandProductsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["brand-products", params],
|
||||||
|
queryFn: () => api.getBrandProducts(params),
|
||||||
|
enabled: !!params.brandId,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useGetBrandProducts } from "./useBrandData";
|
||||||
|
import { CategoryVariant } from "../types/Types";
|
||||||
|
|
||||||
|
interface UseBrandFiltersDataProps {
|
||||||
|
brandId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBrandFiltersData = ({ brandId }: UseBrandFiltersDataProps) => {
|
||||||
|
const [filterState, setFilterState] = useState<{
|
||||||
|
categories: string[];
|
||||||
|
priceRange: [number, number];
|
||||||
|
}>({
|
||||||
|
categories: [],
|
||||||
|
priceRange: [0, 8000000],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useGetBrandProducts({
|
||||||
|
brandId,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
category:
|
||||||
|
filterState.categories.length > 0
|
||||||
|
? filterState.categories.join(",")
|
||||||
|
: undefined,
|
||||||
|
priceMin:
|
||||||
|
filterState.priceRange[0] > 0 ? filterState.priceRange[0] : undefined,
|
||||||
|
priceMax:
|
||||||
|
filterState.priceRange[1] < 8000000
|
||||||
|
? filterState.priceRange[1]
|
||||||
|
: undefined,
|
||||||
|
sort: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// بروزرسانی محدوده قیمت از API
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.results?.filters?.priceRange) {
|
||||||
|
const { minPrice, maxPrice } = data.results.filters.priceRange;
|
||||||
|
setFilterState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
priceRange: [minPrice, maxPrice],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [data?.results?.filters?.priceRange]);
|
||||||
|
|
||||||
|
// نمایش دسته اصلی برند برای ساختار درختی
|
||||||
|
const categories: CategoryVariant[] = data?.results?.category
|
||||||
|
? [data.results.category as CategoryVariant]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const priceMinMaxRange = data?.results?.filters?.priceRange || {
|
||||||
|
minPrice: 0,
|
||||||
|
maxPrice: 8000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCategoryFilter = useCallback((categoryId: string) => {
|
||||||
|
setFilterState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
categories: prev.categories.includes(categoryId)
|
||||||
|
? prev.categories.filter((id) => id !== categoryId)
|
||||||
|
: [...prev.categories, categoryId],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePriceRange = useCallback((range: [number, number]) => {
|
||||||
|
setFilterState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
priceRange: range,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
const defaultPriceRange = data?.results?.filters?.priceRange
|
||||||
|
? [
|
||||||
|
data.results.filters.priceRange.minPrice,
|
||||||
|
data.results.filters.priceRange.maxPrice,
|
||||||
|
]
|
||||||
|
: [0, 8000000];
|
||||||
|
setFilterState({
|
||||||
|
categories: [],
|
||||||
|
priceRange: defaultPriceRange as [number, number],
|
||||||
|
});
|
||||||
|
}, [data?.results?.filters?.priceRange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterState,
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
priceMinMaxRange,
|
||||||
|
applyCategoryFilter,
|
||||||
|
updatePriceRange,
|
||||||
|
clearAllFilters,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/Service";
|
||||||
|
import { BrandResponse } from "../types/Types";
|
||||||
|
|
||||||
|
interface UseInfiniteBrandProductsDataParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
category?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInfiniteBrandProductsData = (
|
||||||
|
brandId: string,
|
||||||
|
params?: UseInfiniteBrandProductsDataParams
|
||||||
|
) => {
|
||||||
|
return useInfiniteQuery<BrandResponse>({
|
||||||
|
queryKey: ["infinite-brand-products", brandId, params],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
api.getBrandProducts({
|
||||||
|
brandId,
|
||||||
|
...params,
|
||||||
|
page: pageParam as number,
|
||||||
|
}),
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const pager = lastPage.results.pager;
|
||||||
|
return pager.nextPage ? pager.page + 1 : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
enabled: !!brandId,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import { BrandResponse, BrandProductsParams } from "../types/Types";
|
||||||
|
|
||||||
|
export const getBrandProducts = async (
|
||||||
|
params: BrandProductsParams
|
||||||
|
): Promise<BrandResponse> => {
|
||||||
|
const {
|
||||||
|
brandId,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sort,
|
||||||
|
priceMin,
|
||||||
|
priceMax,
|
||||||
|
category,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sort) queryParams.append("sort", sort.toString());
|
||||||
|
if (priceMin) queryParams.append("priceMin", priceMin.toString());
|
||||||
|
if (priceMax) queryParams.append("priceMax", priceMax.toString());
|
||||||
|
if (category) queryParams.append("category", category);
|
||||||
|
|
||||||
|
const { data } = await axios.get<BrandResponse>(
|
||||||
|
`/brand/${brandId}/search?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// تایپهای اصلی برند
|
||||||
|
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 ProductImage {
|
||||||
|
cover: string;
|
||||||
|
list: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecification {
|
||||||
|
title: string;
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductBrand {
|
||||||
|
_id: string;
|
||||||
|
status: string;
|
||||||
|
title_en: string;
|
||||||
|
title_fa: string;
|
||||||
|
images: string[];
|
||||||
|
logoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductCategory {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
theme: string;
|
||||||
|
description: string;
|
||||||
|
leaf: boolean;
|
||||||
|
parent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductPrice {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shop {
|
||||||
|
_id: string;
|
||||||
|
shopName: string;
|
||||||
|
shopCode: number;
|
||||||
|
owner: string;
|
||||||
|
shopDescription: string;
|
||||||
|
telephoneNumber: string;
|
||||||
|
isChatActive: boolean;
|
||||||
|
shopPostalCode: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShipmentMethod {
|
||||||
|
_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
deliveryTime: number;
|
||||||
|
deliveryType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warranty {
|
||||||
|
_id: number;
|
||||||
|
duration: string;
|
||||||
|
logoUrl: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meterage {
|
||||||
|
_id: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductVariant {
|
||||||
|
_id: string;
|
||||||
|
market_status: string;
|
||||||
|
price: ProductPrice;
|
||||||
|
stock: number;
|
||||||
|
postingTime: number;
|
||||||
|
isFreeShip: boolean;
|
||||||
|
isWholeSale: boolean;
|
||||||
|
shop: Shop;
|
||||||
|
shipmentMethod: ShipmentMethod[];
|
||||||
|
warranty: Warranty;
|
||||||
|
meterage: Meterage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
_id: number;
|
||||||
|
url: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
seoTitle: string;
|
||||||
|
seoDescription: string | null;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
metaDescription: string;
|
||||||
|
tags: string[];
|
||||||
|
advantages: string[];
|
||||||
|
disAdvantages: string[];
|
||||||
|
imagesUrl: ProductImage;
|
||||||
|
isFake: string;
|
||||||
|
isWished: boolean | null;
|
||||||
|
specifications: ProductSpecification[];
|
||||||
|
brand: ProductBrand;
|
||||||
|
category: ProductCategory;
|
||||||
|
default_variant: ProductVariant;
|
||||||
|
variants: ProductVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپهای فیلتر
|
||||||
|
export interface PriceRange {
|
||||||
|
minPrice: number;
|
||||||
|
maxPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
priceRange: PriceRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپهای دستهبندی
|
||||||
|
export interface CategoryVariant {
|
||||||
|
_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;
|
||||||
|
url: string;
|
||||||
|
children: CategoryVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 | null;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
url: string;
|
||||||
|
children: CategoryVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپهای پاژینیشن
|
||||||
|
export interface Pager {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
prevPage: boolean;
|
||||||
|
nextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپ اصلی پاسخ API
|
||||||
|
export interface BrandResponse {
|
||||||
|
status: number;
|
||||||
|
success: boolean;
|
||||||
|
results: {
|
||||||
|
brand: Brand;
|
||||||
|
products: Product[];
|
||||||
|
filters: Filters;
|
||||||
|
category: Category;
|
||||||
|
pager: Pager;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپهای پارامترهای درخواست
|
||||||
|
export interface BrandParams {
|
||||||
|
id: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sort?: number;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تایپ پارامترهای hook برای دریافت محصولات برند
|
||||||
|
export interface BrandProductsParams {
|
||||||
|
brandId: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sort?: number;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user