From 0fa7fbf99b508558cdd38d51453cf516bb8a12fc Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sat, 27 Sep 2025 11:51:05 +0330 Subject: [PATCH] create discount --- src/components/MultiSelectSearchable.tsx | 197 ++++++++++++++ src/components/ProductMultiSelect.tsx | 112 ++++++++ src/components/Select.tsx | 1 + src/config/Pages.ts | 8 +- src/pages/Coupon/Create.tsx | 272 +++++++++++++++++++ src/pages/Coupon/List.tsx | 23 ++ src/pages/Coupon/hooks/useCouponData.ts | 21 ++ src/pages/Coupon/service/CouponService.ts | 14 + src/pages/Coupon/types/Types.ts | 21 ++ src/pages/products/hooks/useProductData.ts | 8 + src/pages/products/service/ProductService.ts | 47 ++-- src/router/Main.tsx | 5 + src/shared/SideBar.tsx | 8 +- 13 files changed, 712 insertions(+), 25 deletions(-) create mode 100644 src/components/MultiSelectSearchable.tsx create mode 100644 src/components/ProductMultiSelect.tsx create mode 100644 src/pages/Coupon/Create.tsx create mode 100644 src/pages/Coupon/List.tsx create mode 100644 src/pages/Coupon/hooks/useCouponData.ts create mode 100644 src/pages/Coupon/service/CouponService.ts create mode 100644 src/pages/Coupon/types/Types.ts diff --git a/src/components/MultiSelectSearchable.tsx b/src/components/MultiSelectSearchable.tsx new file mode 100644 index 0000000..0ae4cf8 --- /dev/null +++ b/src/components/MultiSelectSearchable.tsx @@ -0,0 +1,197 @@ +import React, { useState, useMemo } from 'react' +import { Check, ChevronsUpDown, X, Search } from 'lucide-react' +import { cn } from '../lib/utils' +import { Button } from './ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from './ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from './ui/popover' + +export type MultiSelectOption = { + value: string + label: string +} + +type MultiSelectSearchableProps = { + options: MultiSelectOption[] + value: string[] + onChange: (value: string[]) => void + onSearch?: (searchTerm: string) => void + onOpenChange?: (open: boolean) => void + placeholder?: string + label?: string + error_text?: string + disabled?: boolean + loading?: boolean +} + +const MultiSelectSearchable: React.FC = ({ + options, + value, + onChange, + onSearch, + onOpenChange, + placeholder = "انتخاب کنید...", + label, + error_text, + disabled = false, + loading = false, +}) => { + const [open, setOpen] = useState(false) + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + onOpenChange?.(newOpen) + } + + const selectedLabels = useMemo(() => { + return value + .map(v => options.find(option => option.value === v)?.label) + .filter(Boolean) + }, [value, options]) + + const handleSelect = (currentValue: string) => { + const newValue = value.includes(currentValue) + ? value.filter(v => v !== currentValue) + : [...value, currentValue] + onChange(newValue) + } + + const handleRemove = (itemValue: string) => { + onChange(value.filter(v => v !== itemValue)) + } + + return ( +
+ {label && ( + + )} + + + + + + { + // Prevent event bubbling that might cause navigation + e.stopPropagation() + }} + > + { + // Prevent any navigation or form submission from command + e.stopPropagation() + }} + > +
+ + { + e.preventDefault() + onSearch?.(e.target.value) + }} + onKeyDown={(e) => { + // Prevent form submission or other default behaviors + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + } + }} + /> +
+ + موردی یافت نشد. + + {options.map((option) => { + const isSelected = value.includes(option.value) + return ( + { + handleSelect(option.value) + }} + className="flex items-center" + > + + + {option.label} + + + ) + })} + + +
+
+
+ + {error_text && ( +
{error_text}
+ )} +
+ ) +} + +export default MultiSelectSearchable diff --git a/src/components/ProductMultiSelect.tsx b/src/components/ProductMultiSelect.tsx new file mode 100644 index 0000000..6f0820a --- /dev/null +++ b/src/components/ProductMultiSelect.tsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { useSearchProducts } from '../pages/products/hooks/useProductData' +import { type ProductListType } from '../pages/products/types/Types' +import MultiSelectSearchable, { type MultiSelectOption } from './MultiSelectSearchable' + +type ProductMultiSelectProps = { + value: number[] + onChange: (value: number[]) => void + placeholder?: string + label?: string + error_text?: string + disabled?: boolean +} + +const ProductMultiSelect: React.FC = ({ + value, + onChange, + placeholder = "انتخاب محصولات...", + label, + error_text, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState('') + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') + const [selectedProducts, setSelectedProducts] = useState([]) + const [dropdownOpen, setDropdownOpen] = useState(false) + + // Debounce search term + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm) + }, 300) + + return () => clearTimeout(timer) + }, [searchTerm]) + + // Search products (includes initial load and search) + const { data: searchResults, isLoading } = useSearchProducts(debouncedSearchTerm, dropdownOpen) + + // Combine selected products with search results + const allProducts = useMemo(() => { + const productsMap = new Map() + + // Add selected products + selectedProducts.forEach(product => { + productsMap.set(product._id.toString(), product) + }) + + // Add search results + if (searchResults?.results?.products) { + searchResults.results.products.forEach((product: ProductListType) => { + productsMap.set(product._id.toString(), product) + }) + } + + return Array.from(productsMap.values()) + }, [selectedProducts, searchResults]) + + // Convert to options format + const options: MultiSelectOption[] = useMemo(() => { + return allProducts.map(product => ({ + value: product._id.toString(), + label: product.title_fa || product.title_en + })) + }, [allProducts]) + + const handleChange = (newValue: string[]) => { + // Update selected products list + const newSelectedProducts = allProducts.filter(product => + newValue.includes(product._id.toString()) + ) + setSelectedProducts(newSelectedProducts) + + // Convert back to numbers for the parent component + onChange(newValue.map(v => parseInt(v))) + } + + // Custom search handler for MultiSelectSearchable + const handleSearch = (term: string) => { + setSearchTerm(term) + } + + return ( +
+ {label && ( + + )} + + v.toString())} + onChange={handleChange} + onSearch={handleSearch} + onOpenChange={setDropdownOpen} + placeholder={placeholder} + error_text={error_text} + disabled={disabled} + loading={isLoading} + /> + + {dropdownOpen && !isLoading && allProducts.length === 0 && ( +
+ {searchTerm ? 'محصولی یافت نشد.' : 'برای جستجو نام محصول را تایپ کنید.'} +
+ )} +
+ ) +} + +export default ProductMultiSelect diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 046e424..b5308e7 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -13,6 +13,7 @@ type Props = { error_text?: string, placeholder?: string, label?: string, + value?: any, } & SelectHTMLAttributes const Select: FC = (props: Props) => { diff --git a/src/config/Pages.ts b/src/config/Pages.ts index b754f61..6524ac4 100644 --- a/src/config/Pages.ts +++ b/src/config/Pages.ts @@ -64,10 +64,10 @@ export const Pages = { developers: { list: "/developers/list", }, - discount: { - list: "/discounts/list", - create: "/discounts/create", - detail: "/discounts/detail/", + coupon: { + list: "/coupons/list", + create: "/coupons/create", + detail: "/coupons/update/", }, referralCode: { list: "/referral-code/list", diff --git a/src/pages/Coupon/Create.tsx b/src/pages/Coupon/Create.tsx new file mode 100644 index 0000000..480b218 --- /dev/null +++ b/src/pages/Coupon/Create.tsx @@ -0,0 +1,272 @@ +import { useFormik } from 'formik' +import { type FC } from 'react' +import { type CreateCouponType } from './types/Types' +import * as Yup from 'yup' +import { useCreateCoupon } from './hooks/useCouponData' +import { useNavigate } from 'react-router-dom' +import { useGetCategories, type CategoryTreeNode } from '../category' +import Input from '../../components/Input' +import DatePicker from '../../components/DatePicker' +import Switch from '../../components/Switch' +import Button from '../../components/Button' +import Textarea from '../../components/Textarea' +import MultiSelectSearchable from '../../components/MultiSelectSearchable' +import ProductMultiSelect from '../../components/ProductMultiSelect' +import { TickCircle } from 'iconsax-react' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/helpers/utils' +import { Pages } from '../../config/Pages' + +const CreateCoupon: FC = () => { + const navigate = useNavigate() + const { data: categoriesData } = useGetCategories() + const createCouponMutation = useCreateCoupon() + + const formik = useFormik({ + initialValues: { + discountPercentage: 0, + discountAmount: 0, + usageLimit: 1, + userUsageLimit: 1, + minPurchaseAmount: 0, + category: [], + includedProducts: [], + excludedProducts: [], + description: '', + includeSpecialSale: false, + expirationDate: '', + }, + validationSchema: Yup.object({ + usageLimit: Yup.number().min(1, 'محدودیت استفاده باید حداقل ۱ باشد').required('محدودیت استفاده الزامی است'), + userUsageLimit: Yup.number().min(1, 'محدودیت استفاده کاربر باید حداقل ۱ باشد').required('محدودیت استفاده کاربر الزامی است'), + description: Yup.string().required('توضیحات کوپن الزامی است'), + expirationDate: Yup.string().required('تاریخ انقضا الزامی است'), + discountPercentage: Yup.number().optional(), + discountAmount: Yup.number().optional(), + minPurchaseAmount: Yup.number().min(0, 'حداقل مبلغ خرید نمی‌تواند منفی باشد').optional(), + includedProducts: Yup.array().optional(), + excludedProducts: Yup.array().optional(), + }).test('at-least-one-discount', 'درصد تخفیف یا مبلغ تخفیف الزامی است', function (value) { + const { discountPercentage, discountAmount } = value; + + // چک کردن اینکه حداقل یکی از دو فیلد پر شده باشد + if ((!discountPercentage || discountPercentage === 0) && (!discountAmount || discountAmount === 0)) { + return this.createError({ + path: 'discountPercentage', + message: 'درصد تخفیف یا مبلغ تخفیف الزامی است', + }); + } + + // چک کردن اینکه هر دو فیلد همزمان پر نشده باشند + if ((discountPercentage && discountPercentage > 0) && (discountAmount && discountAmount > 0)) { + return this.createError({ + path: 'discountPercentage', + message: 'نباید هر دو مقدار مبلغ تخفیف و درصد را وارد کنید', + }); + } + + if (discountPercentage && (discountPercentage < 1 || discountPercentage > 100)) { + return this.createError({ + path: 'discountPercentage', + message: 'درصد تخفیف باید بین ۱ تا ۱۰۰ باشد', + }); + } + + if (discountAmount && discountAmount < 1) { + return this.createError({ + path: 'discountAmount', + message: 'مبلغ تخفیف باید حداقل ۱ باشد', + }); + } + + return true; + }).test('products-validation', 'محصولات شامل و مستثنی نمی‌توانند همزمان انتخاب شوند', function (value) { + const { includedProducts, excludedProducts } = value; + + // چک کردن اینکه هر دو فیلد همزمان پر نشده باشند + if (includedProducts && includedProducts.length > 0 && excludedProducts && excludedProducts.length > 0) { + return this.createError({ + path: 'includedProducts', + message: 'نباید هر دو مقدار محصولات شامل و محصولات مستثنی را وارد کنید', + }); + } + + return true; + }), + onSubmit: async (values) => { + // تبدیل مقادیر به نوع مناسب + // اگر هر دو فیلد تخفیف پر شده باشند، فقط یکی را ارسال می‌کنیم + let discountPercentage: number | undefined + let discountAmount: number | undefined + + if (values.discountPercentage && values.discountPercentage > 0) { + discountPercentage = Number(values.discountPercentage) + discountAmount = undefined + } else if (values.discountAmount && values.discountAmount > 0) { + discountAmount = Number(values.discountAmount) + discountPercentage = undefined + } else { + discountPercentage = undefined + discountAmount = undefined + } + + const submitData = { + ...values, + usageLimit: Number(values.usageLimit), + userUsageLimit: Number(values.userUsageLimit), + discountPercentage, + discountAmount, + minPurchaseAmount: values.minPurchaseAmount ? Number(values.minPurchaseAmount) : undefined, + includedProducts: values.includedProducts?.length ? values.includedProducts : undefined, + excludedProducts: values.excludedProducts?.length ? values.excludedProducts : undefined, + } + + createCouponMutation.mutate(submitData, { + onSuccess: () => { + toast.success('کوپن با موفقیت ایجاد شد') + navigate(Pages.coupon.list) + }, + onError: (error) => { + toast.error(extractErrorMessage(error)) + } + }) + } + }) + + const categoryOptions = categoriesData?.results?.data?.map((category: CategoryTreeNode) => ({ + value: category._id, + label: category.title_fa + })) || [] + + return ( +
+
+
+

ساخت کوپن جدید

+
+
+ +
+
+ +
+ {/* اطلاعات اصلی */} +
+

اطلاعات اصلی

+ +
+
+ + +
+ +
+ + +
+ + + + formik.setFieldValue('expirationDate', date)} + error_text={formik.touched.expirationDate && formik.errors.expirationDate ? formik.errors.expirationDate : ''} + /> + +