brand page

This commit is contained in:
hamid zarghami
2025-09-20 10:46:45 +03:30
parent 4ed55bd1b7
commit 5adf5fe461
14 changed files with 1471 additions and 0 deletions
+229
View File
@@ -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
+93
View File
@@ -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
+41
View File
@@ -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
+119
View File
@@ -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
+90
View File
@@ -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
+128
View File
@@ -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
+111
View File
@@ -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
+11
View File
@@ -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,
});
};
+31
View File
@@ -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;
};
+219
View File
@@ -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;
}