From 477b42cd3157b71cf87d4be878571d5c004c269a Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Thu, 4 Sep 2025 12:44:40 +0330 Subject: [PATCH] api attachment product list + filter --- package-lock.json | 46 ++++ package.json | 1 + src/app/products/[id]/page.tsx | 152 ++++++++++-- src/app/products/components/CategoryTree.tsx | 55 ++++- src/app/products/components/Filters.tsx | 241 +++++++++++++------ src/app/products/components/Pagination.tsx | 116 +++++++++ src/app/products/components/Sorts.tsx | 48 +++- src/app/products/hooks/useProductsData.ts | 36 +++ src/app/products/service/Service.ts | 47 ++++ src/components/ProductCard.tsx | 32 ++- src/components/ui/slider.tsx | 63 +++++ src/share/Footer.tsx | 6 +- src/types/landing.types.ts | 37 +++ src/types/products.types.ts | 200 +++++++++++++++ 14 files changed, 952 insertions(+), 128 deletions(-) create mode 100644 src/app/products/components/Pagination.tsx create mode 100644 src/app/products/hooks/useProductsData.ts create mode 100644 src/app/products/service/Service.ts create mode 100644 src/components/ui/slider.tsx create mode 100644 src/types/products.types.ts diff --git a/package-lock.json b/package-lock.json index ecd4ae3..9f5c222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 179f473..5583af7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx index adddb5b..ca54c9c 100644 --- a/src/app/products/[id]/page.tsx +++ b/src/app/products/[id]/page.tsx @@ -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 (
@@ -27,14 +117,22 @@ const Products: NextPage = () => { فروشگاه آناهیتا - / - - بهداشت خانگی - - / - - مواد شوینده - + {data?.results.breadcrumb?.map((item, index) => ( +
+ / + + {index === data.results.breadcrumb.length - 1 ? ( + + {item.title_fa} + + ) : ( + + {item.title_fa} + + )} + +
+ ))} @@ -51,26 +149,46 @@ const Products: NextPage = () => {
{/* Desktop Filters */} - + {/* Mobile Filters Modal */} setShowMobileFilters(false)} + categoryUrl={categoryUrl} />
- +
- - - - - - + {isLoading ? ( +
+
در حال بارگذاری...
+
+ ) : error ? ( +
+
خطا در بارگذاری محصولات
+
+ ) : data?.results.products ? ( + + {data.results.products.map((product) => ( + + ))} + + ) : ( +
+
محصولی یافت نشد
+
+ )}
+ + {/* Pagination */} +
diff --git a/src/app/products/components/CategoryTree.tsx b/src/app/products/components/CategoryTree.tsx index a5dcbc8..41498c5 100644 --- a/src/app/products/components/CategoryTree.tsx +++ b/src/app/products/components/CategoryTree.tsx @@ -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 = ({ categories, selectedId, onSelect }) => { +const CategoryTree: FC = ({ 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 (
- onSelect(null)} - /> {roots.map((cat) => ( - + ))}
) @@ -40,8 +66,11 @@ const Node: FC = ({ 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 = ({ node, selectedId, onSelect, depth }) => { selectedId={selectedId} onSelect={onSelect} depth={depth + 1} - />) - )} + /> + ))} )} diff --git a/src/app/products/components/Filters.tsx b/src/app/products/components/Filters.tsx index 64dfd0e..46ca3fd 100644 --- a/src/app/products/components/Filters.tsx +++ b/src/app/products/components/Filters.tsx @@ -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 = ({ isMobile = false, isOpen = false, onClose }) => { +const Filters: FC = ({ isMobile = false, isOpen = false, onClose, categoryUrl = 'Organic-supermarket' }) => { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() const [selectedCategories, setSelectedCategories] = useState([]) - const [minPrice, setMinPrice] = useState('') - const [maxPrice, setMaxPrice] = useState('') + const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000000]) const [inStockOnly, setInStockOnly] = useState(false) - const [rating, setRating] = useState(null) + // const [rating, setRating] = useState(null) + const [selectedBrand, setSelectedBrand] = useState('') + const [wholeSale, setWholeSale] = useState(false) - const { data: categoriesData } = useQuery({ - queryKey: ['categories'], - queryFn: getCategories, - staleTime: 1000 * 60 * 10, + // ref برای نگهداری timeout ID + const timeoutRef = useRef(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 = ({ isMobile = false, isOpen = false, onClose } 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} /> @@ -112,23 +147,24 @@ const Filters: FC = ({ isMobile = false, isOpen = false, onClose } قیمت -
- setMinPrice(e.target.value)} - placeholder='0' - seprator - inputMode='numeric' - /> - setMaxPrice(e.target.value)} - placeholder='0' - seprator - inputMode='numeric' - /> +
+
+ { + setPriceRange(value as [number, number]) + applyPriceFilter(value as [number, number]) + }} + min={0} + max={1000000} + step={10000} + className='w-full' + /> +
+
+ حداقل: {priceRange[0].toLocaleString('fa-IR')} تومان + حداکثر: {priceRange[1].toLocaleString('fa-IR')} تومان +
@@ -141,45 +177,92 @@ const Filters: FC = ({ 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()}`) + }} /> فقط کالاهای موجود - - امتیاز خریداران + + برند -
- {[4, 3, 2].map((r) => ( -
-
- - +
+
) diff --git a/src/app/products/components/Pagination.tsx b/src/app/products/components/Pagination.tsx new file mode 100644 index 0000000..af5c4d6 --- /dev/null +++ b/src/app/products/components/Pagination.tsx @@ -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 = ({ 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 ( +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {pageNumbers.map((page, index) => ( +
+ {page === '...' ? ( + ... + ) : ( + + )} +
+ ))} +
+ + {/* Next Button */} + +
+ ) +} + +export default Pagination diff --git a/src/app/products/components/Sorts.tsx b/src/app/products/components/Sorts.tsx index d9ff742..aa73052 100644 --- a/src/app/products/components/Sorts.tsx +++ b/src/app/products/components/Sorts.tsx @@ -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 = ({ 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(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 = () => {
setSelectedSort(option.key)} + onClick={() => handleSortChange(option.key)} > {option.label}
@@ -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 = () => {
- 100 کالا + {data?.results?.pager?.totalItems || 0} کالا
) diff --git a/src/app/products/hooks/useProductsData.ts b/src/app/products/hooks/useProductsData.ts new file mode 100644 index 0000000..3475be2 --- /dev/null +++ b/src/app/products/hooks/useProductsData.ts @@ -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({ + queryKey: ["products", params], + queryFn: () => api.getProducts(params), + }); +}; + +export const useCategoryProductsData = ( + categoryUrl: string, + params?: UseProductsDataParams +) => { + return useQuery({ + queryKey: ["category-products", categoryUrl, params], + queryFn: () => api.getCategoryProducts(categoryUrl, params), + }); +}; diff --git a/src/app/products/service/Service.ts b/src/app/products/service/Service.ts new file mode 100644 index 0000000..a838717 --- /dev/null +++ b/src/app/products/service/Service.ts @@ -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 => { + 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 => { + const { data } = await axios.get(`/category/${categoryUrl}/search`, { + params, + }); + return data; +}; diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index e00df26..740fd58 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -25,13 +25,37 @@ const ProductCard: FC = ({ item }) => {

{item.title_fa}

+ {/*
-
-
-
-
+ { + item.variants.map((variant) => { + if (variant.meterage) { + return
{variant?.meterage?.value}
+ } + + }) + }
+
+ { + item.variants.map((variant) => { + if (variant.color) { + return
+
+
+ } + }) + } + { + item.variants.map((variant) => { + if (variant.size) { + return
{variant?.size?.value}
+ } + }) + } +
*/} +
{NumberFormat(item.default_variant.price.selling_price)} تومان
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..09391e8 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/src/share/Footer.tsx b/src/share/Footer.tsx index 9c28c03..0c4c047 100644 --- a/src/share/Footer.tsx +++ b/src/share/Footer.tsx @@ -39,7 +39,7 @@ const Footer: FC = () => {
-
+
دانلود اپلیکیشن
@@ -61,7 +61,7 @@ const Footer: FC = () => {
-
+
با ثبت ایمیل از آخرین تخفیف ها با خبر شوید
@@ -77,7 +77,7 @@ const Footer: FC = () => {
-
+
ما را در شبکه های اجتماعی دنبال کنید
diff --git a/src/types/landing.types.ts b/src/types/landing.types.ts index 8947a8d..0b4de28 100644 --- a/src/types/landing.types.ts +++ b/src/types/landing.types.ts @@ -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 { diff --git a/src/types/products.types.ts b/src/types/products.types.ts new file mode 100644 index 0000000..4a5fe81 --- /dev/null +++ b/src/types/products.types.ts @@ -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; + }; +}