create coupon

This commit is contained in:
hamid zarghami
2025-12-06 16:38:41 +03:30
parent f397560312
commit 6447177b05
13 changed files with 630 additions and 3 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { type FC, useEffect, useState } from 'react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import 'swiper/swiper-bundle.css'; import 'swiper/swiper-bundle.css';
// import 'rc-rate/assets/index.css'; // import 'rc-rate/assets/index.css';
// import "react-multi-date-picker/styles/layouts/mobile.css" import "react-multi-date-picker/styles/layouts/mobile.css"
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import './assets/fonts/irancell/style.css' import './assets/fonts/irancell/style.css'
import i18next from 'i18next' import i18next from 'i18next'
+1 -1
View File
@@ -28,7 +28,7 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
useEffect(() => { useEffect(() => {
if (value) { if (value) {
const formattedDate = `${value.year}/${value.month.number}/${value.day}`; const formattedDate = `${value.year}-${value.month.number}-${value.day}`;
props.onChange(formattedDate); props.onChange(formattedDate);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+137
View File
@@ -0,0 +1,137 @@
import { type FC, useState, useMemo } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useNavigate } from 'react-router-dom'
import Button from '@/components/Button'
import type { CreateCouponType } from './types/Types'
import { useCreateCoupon } from './hooks/useCouponData'
import { useGetCategories } from '@/pages/food/hooks/useFoodData'
import { useGetFoods } from '@/pages/food/hooks/useFoodData'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
import CouponBasicInfo from './components/CouponBasicInfo'
import CouponDiscountSettings from './components/CouponDiscountSettings'
import CouponUsageSettings from './components/CouponUsageSettings'
import CouponSidebar from './components/CouponSidebar'
const CreateCoupon: FC = () => {
const navigate = useNavigate()
const { mutate: createCoupon, isPending: isCreating } = useCreateCoupon()
const { data: categoriesData } = useGetCategories()
const { data: foodsData } = useGetFoods()
const [discountType, setDiscountType] = useState<'DIRECT' | 'CODE'>('CODE')
const [couponType, setCouponType] = useState<'PERCENTAGE' | 'FIXED'>('PERCENTAGE')
const categories = useMemo(() => categoriesData?.data || [], [categoriesData?.data])
const foods = useMemo(() => foodsData?.data || [], [foodsData?.data])
const formik = useFormik<CreateCouponType>({
initialValues: {
code: '',
name: '',
description: '',
type: 'PERCENTAGE',
value: 0,
maxDiscount: null,
minOrderAmount: 0,
maxUses: 0,
maxUsesPerUser: null,
startDate: null,
endDate: null,
isActive: true,
categoryIds: [],
foodIds: [],
},
validationSchema: Yup.object().shape({
code: Yup.string().required('کد کوپن الزامی است'),
name: Yup.string().required('نام کوپن الزامی است'),
description: Yup.string(),
type: Yup.string().required('نوع کوپن الزامی است'),
value: Yup.number().min(0, 'مقدار باید بیشتر از صفر باشد').required('مقدار کوپن الزامی است'),
maxDiscount: Yup.number().nullable().min(0, 'حداکثر تخفیف باید بیشتر از صفر باشد'),
minOrderAmount: Yup.number().min(0, 'حداقل مبلغ سفارش باید بیشتر از صفر باشد').required('حداقل مبلغ سفارش الزامی است'),
maxUses: Yup.number().min(0, 'حداکثر استفاده باید بیشتر از صفر باشد').required('حداکثر استفاده الزامی است'),
maxUsesPerUser: Yup.number().nullable().min(0, 'حداکثر استفاده برای هر کاربر باید بیشتر از صفر باشد'),
startDate: Yup.string().nullable(),
endDate: Yup.string().nullable(),
}),
onSubmit: (values) => {
createCoupon(values, {
onSuccess: () => {
toast.success('کوپن با موفقیت ایجاد شد')
navigate(Pages.coupons.list)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
},
})
},
})
const generateRandomCode = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let code = ''
for (let i = 0; i < 8; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
formik.setFieldValue('code', code)
}
return (
<div className='mt-5'>
<div className='flex justify-between items-center mb-6'>
<h1 className='text-lg font-light'>کد تخفیف جدید</h1>
</div>
<div className='flex justify-end'>
<Button
className='flex gap-4 w-fit px-5'
isloading={isCreating}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'>
<TickCircle color='white' size={20} className='ml-2' />
<span>ثبت تخفیف</span>
</div>
</Button>
</div>
<div className='flex gap-7 mt-7'>
<div className='flex-1'>
<div className='bg-white rounded-4xl p-8'>
<div className='text-lg font-light mb-6'>کد تخفیف جدید</div>
<CouponBasicInfo
formik={formik}
discountType={discountType}
onDiscountTypeChange={setDiscountType}
onGenerateCode={generateRandomCode}
/>
<CouponDiscountSettings
formik={formik}
couponType={couponType}
onCouponTypeChange={setCouponType}
/>
<CouponUsageSettings formik={formik} />
</div>
</div>
<div className='w-80 flex-shrink-0'>
<CouponSidebar
formik={formik}
categories={categories}
foods={foods}
/>
</div>
</div>
</div>
)
}
export default CreateCoupon
@@ -0,0 +1,86 @@
import { type FC, useState, useMemo } from 'react'
import type { FormikProps } from 'formik'
import Input from '@/components/Input'
import { Checkbox } from '@/components/ui/checkbox'
import type { CreateCouponType } from '../types/Types'
interface Category {
id: string
title: string
}
interface CategorySelectorProps {
formik: FormikProps<CreateCouponType>
categories: Category[]
}
const CategorySelector: FC<CategorySelectorProps> = ({ formik, categories }) => {
const [search, setSearch] = useState<string>('')
const filteredCategories = useMemo(() => {
if (!search) return categories
return categories.filter((cat) =>
cat.title.toLowerCase().includes(search.toLowerCase())
)
}, [categories, search])
const handleToggle = (categoryId: string, checked: boolean) => {
const currentIds = formik.values.categoryIds || []
if (checked) {
formik.setFieldValue('categoryIds', [...currentIds, categoryId])
} else {
formik.setFieldValue('categoryIds', currentIds.filter((id) => id !== categoryId))
}
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
formik.setFieldValue('categoryIds', filteredCategories.map((cat) => cat.id))
} else {
formik.setFieldValue('categoryIds', [])
}
}
const isAllSelected = useMemo(() => {
return filteredCategories.length > 0 &&
filteredCategories.every((cat) => formik.values.categoryIds?.includes(cat.id))
}, [filteredCategories, formik.values.categoryIds])
return (
<div>
<div className='text-sm mb-3'>دسته بندیهایی که شامل تخفیف میشوند</div>
<div className='mb-6 border border-border rounded-xl p-4'>
<Input
variant='search'
className='bg-[#EEF0F7]'
placeholder='جستجو'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className='mt-3 space-y-2 max-h-60 overflow-y-auto'>
<div className='flex items-center gap-2'>
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<label className='text-sm cursor-pointer'>همه</label>
</div>
{filteredCategories.map((category) => (
<div key={category.id} className='flex items-center gap-2'>
<Checkbox
checked={formik.values.categoryIds?.includes(category.id) || false}
onCheckedChange={(checked) =>
handleToggle(category.id, checked as boolean)
}
/>
<label className='text-sm cursor-pointer'>{category.title}</label>
</div>
))}
</div>
</div>
</div>
)
}
export default CategorySelector
@@ -0,0 +1,89 @@
import { type FC } from 'react'
import { Refresh } from 'iconsax-react'
import type { FormikProps } from 'formik'
import Input from '@/components/Input'
import Textarea from '@/components/Textarea'
import RadioGroup from '@/components/RadioGroup'
import Button from '@/components/Button'
import type { CreateCouponType } from '../types/Types'
interface CouponBasicInfoProps {
formik: FormikProps<CreateCouponType>
discountType: 'DIRECT' | 'CODE'
onDiscountTypeChange: (value: 'DIRECT' | 'CODE') => void
onGenerateCode?: () => void
}
const CouponBasicInfo: FC<CouponBasicInfoProps> = ({
formik,
discountType,
onDiscountTypeChange,
onGenerateCode,
}) => {
return (
<>
<div className='mb-6'>
<div className='text-sm mb-3'>نوع تخفیف</div>
<RadioGroup
items={[
{ label: 'تخفیف مستقیم', value: 'DIRECT' },
{ label: 'کد تخفیف', value: 'CODE' },
]}
selected={discountType}
onChange={(value) => onDiscountTypeChange(value as 'DIRECT' | 'CODE')}
/>
</div>
<div className='mb-6'>
<div className='flex items-end gap-2'>
<div className='flex-1'>
<Input
label='عنوان کد'
placeholder=''
name='name'
value={formik.values.name}
onChange={formik.handleChange}
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ''}
/>
</div>
</div>
</div>
<div className='mb-6'>
<Textarea
label='توضیحات'
placeholder='توضیحات کوپن را وارد کنید'
name='description'
value={formik.values.description}
onChange={formik.handleChange}
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
/>
</div>
<div className='mb-6'>
<div className='flex items-end gap-2'>
<div className='flex-1'>
<Input
label='کد تخفیف'
placeholder=''
name='code'
value={formik.values.code}
onChange={formik.handleChange}
error_text={formik.touched.code && formik.errors.code ? formik.errors.code : ''}
/>
</div>
<Button
className='w-auto bg-transparent px-4 h-10 text-black border border-primary'
onClick={onGenerateCode}
>
<Refresh color='black' size={20} className='ml-2' />
ساخت کد
</Button>
</div>
</div>
</>
)
}
export default CouponBasicInfo
@@ -0,0 +1,82 @@
import { type FC } from 'react'
import type { FormikProps } from 'formik'
import Input from '@/components/Input'
import RadioGroup from '@/components/RadioGroup'
import type { CreateCouponType } from '../types/Types'
interface CouponDiscountSettingsProps {
formik: FormikProps<CreateCouponType>
couponType: 'PERCENTAGE' | 'FIXED'
onCouponTypeChange: (value: 'PERCENTAGE' | 'FIXED') => void
}
const CouponDiscountSettings: FC<CouponDiscountSettingsProps> = ({
formik,
couponType,
onCouponTypeChange,
}) => {
return (
<>
<div className='mb-6'>
<div className='text-sm mb-3'>مبلغ تخفیف</div>
<RadioGroup
items={[
{ label: 'مبلغ ثابت', value: 'FIXED' },
{ label: 'درصد', value: 'PERCENTAGE' },
]}
selected={couponType}
onChange={(value) => {
onCouponTypeChange(value as 'PERCENTAGE' | 'FIXED')
formik.setFieldValue('type', value as 'PERCENTAGE' | 'FIXED')
}}
/>
</div>
<div className='mb-6'>
<Input
label={couponType === 'PERCENTAGE' ? 'درصد تخفیف' : 'مبلغ تخفیف'}
placeholder={couponType === 'PERCENTAGE' ? '%' : 'تومان'}
name='value'
type='number'
value={formik.values.value}
onChange={(e) => formik.setFieldValue('value', Number(e.target.value))}
error_text={formik.touched.value && formik.errors.value ? formik.errors.value : ''}
/>
</div>
{couponType === 'PERCENTAGE' && (
<div className='mb-6'>
<Input
label='حداکثر تخفیف'
placeholder='تومان'
name='maxDiscount'
type='number'
seprator={true}
value={formik.values.maxDiscount || ''}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : null
formik.setFieldValue('maxDiscount', value)
}}
error_text={formik.touched.maxDiscount && formik.errors.maxDiscount ? formik.errors.maxDiscount : ''}
/>
</div>
)}
<div className='mb-6'>
<Input
label='حداقل مبلغ سفارش'
placeholder='تومان'
name='minOrderAmount'
type='number'
seprator={true}
value={formik.values.minOrderAmount}
onChange={(e) => formik.setFieldValue('minOrderAmount', Number(e.target.value))}
error_text={formik.touched.minOrderAmount && formik.errors.minOrderAmount ? formik.errors.minOrderAmount : ''}
/>
</div>
</>
)
}
export default CouponDiscountSettings
@@ -0,0 +1,43 @@
import { type FC } from 'react'
import type { FormikProps } from 'formik'
import SwitchComponent from '@/components/Switch'
import CategorySelector from './CategorySelector'
import FoodSelector from './FoodSelector'
import type { CreateCouponType } from '../types/Types'
interface Category {
id: string
title: string
}
interface Food {
id: string
title: string
}
interface CouponSidebarProps {
formik: FormikProps<CreateCouponType>
categories: Category[]
foods: Food[]
}
const CouponSidebar: FC<CouponSidebarProps> = ({ formik, categories, foods }) => {
return (
<div className='bg-white rounded-4xl p-6'>
<div className='mb-6'>
<div className='text-sm mb-3'>وضعیت تخفیف</div>
<SwitchComponent
active={formik.values.isActive}
onChange={(value) => formik.setFieldValue('isActive', value)}
label={formik.values.isActive ? 'فعال' : 'غیرفعال'}
/>
</div>
<CategorySelector formik={formik} categories={categories} />
<FoodSelector formik={formik} foods={foods} />
</div>
)
}
export default CouponSidebar
@@ -0,0 +1,68 @@
import { type FC } from 'react'
import moment from 'moment-jalaali'
import type { FormikProps } from 'formik'
import Input from '@/components/Input'
import DatePickerComponent from '@/components/DatePicker'
import type { CreateCouponType } from '../types/Types'
interface CouponUsageSettingsProps {
formik: FormikProps<CreateCouponType>
}
const CouponUsageSettings: FC<CouponUsageSettingsProps> = ({ formik }) => {
const convertToGregorian = (persianDate: string): string | null => {
if (!persianDate) return null
const gregorianDate = moment(persianDate, 'jYYYY-jMM-jDD').format('YYYY-MM-DD')
return gregorianDate
}
return (
<>
<div className='grid grid-cols-2 gap-4 mb-6'>
<Input
label='حداکثر استفاده'
placeholder=''
name='maxUses'
type='number'
value={formik.values.maxUses}
onChange={(e) => formik.setFieldValue('maxUses', Number(e.target.value))}
error_text={formik.touched.maxUses && formik.errors.maxUses ? formik.errors.maxUses : ''}
/>
<Input
label='حداکثر استفاده برای هر کاربر'
placeholder=''
name='maxUsesPerUser'
type='number'
value={formik.values.maxUsesPerUser || ''}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : null
formik.setFieldValue('maxUsesPerUser', value)
}}
error_text={formik.touched.maxUsesPerUser && formik.errors.maxUsesPerUser ? formik.errors.maxUsesPerUser : ''}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<DatePickerComponent
label='تاریخ شروع'
placeholder='تاریخ شروع را انتخاب کنید'
onChange={(date) => {
const gregorianDate = convertToGregorian(date)
formik.setFieldValue('startDate', gregorianDate)
}}
/>
<DatePickerComponent
label='تاریخ پایان'
placeholder='تاریخ پایان را انتخاب کنید'
onChange={(date) => {
const gregorianDate = convertToGregorian(date)
formik.setFieldValue('endDate', gregorianDate)
}}
/>
</div>
</>
)
}
export default CouponUsageSettings
@@ -0,0 +1,86 @@
import { type FC, useState, useMemo } from 'react'
import type { FormikProps } from 'formik'
import Input from '@/components/Input'
import { Checkbox } from '@/components/ui/checkbox'
import type { CreateCouponType } from '../types/Types'
interface Food {
id: string
title: string
}
interface FoodSelectorProps {
formik: FormikProps<CreateCouponType>
foods: Food[]
}
const FoodSelector: FC<FoodSelectorProps> = ({ formik, foods }) => {
const [search, setSearch] = useState<string>('')
const filteredFoods = useMemo(() => {
if (!search) return foods
return foods.filter((food) =>
food.title.toLowerCase().includes(search.toLowerCase())
)
}, [foods, search])
const handleToggle = (foodId: string, checked: boolean) => {
const currentIds = formik.values.foodIds || []
if (checked) {
formik.setFieldValue('foodIds', [...currentIds, foodId])
} else {
formik.setFieldValue('foodIds', currentIds.filter((id) => id !== foodId))
}
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
formik.setFieldValue('foodIds', filteredFoods.map((food) => food.id))
} else {
formik.setFieldValue('foodIds', [])
}
}
const isAllSelected = useMemo(() => {
return filteredFoods.length > 0 &&
filteredFoods.every((food) => formik.values.foodIds?.includes(food.id))
}, [filteredFoods, formik.values.foodIds])
return (
<div>
<div className='text-sm mb-3'>غذاهایی که شامل تخفیف میشوند</div>
<div className='mb-6 border border-border rounded-xl p-4'>
<Input
variant='search'
className='bg-[#EEF0F7]'
placeholder='جستجو'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className='mt-3 space-y-2 max-h-60 overflow-y-auto'>
<div className='flex items-center gap-2'>
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<label className='text-sm cursor-pointer'>همه</label>
</div>
{filteredFoods.map((food) => (
<div key={food.id} className='flex items-center gap-2'>
<Checkbox
checked={formik.values.foodIds?.includes(food.id) || false}
onCheckedChange={(checked) =>
handleToggle(food.id, checked as boolean)
}
/>
<label className='text-sm cursor-pointer'>{food.title}</label>
</div>
))}
</div>
</div>
</div>
)
}
export default FoodSelector
+12
View File
@@ -19,3 +19,15 @@ export const useDeleteCoupon = () => {
}, },
}); });
}; };
export const useCreateCoupon = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createCoupon,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["coupons"],
});
},
});
};
+6 -1
View File
@@ -1,11 +1,16 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import type { IGetCouponsResponse } from "../types/Types"; import type { CreateCouponType, IGetCouponsResponse } from "../types/Types";
export const getCoupons = async (): Promise<IGetCouponsResponse> => { export const getCoupons = async (): Promise<IGetCouponsResponse> => {
const { data } = await axios.get<IGetCouponsResponse>("/admin/coupons"); const { data } = await axios.get<IGetCouponsResponse>("/admin/coupons");
return data; return data;
}; };
export const createCoupon = async (params: CreateCouponType) => {
const { data } = await axios.post("/admin/coupons", params);
return data;
};
export const deleteCoupon = async (id: string) => { export const deleteCoupon = async (id: string) => {
const { data } = await axios.delete(`/admin/coupons/${id}`); const { data } = await axios.delete(`/admin/coupons/${id}`);
return data; return data;
+17
View File
@@ -25,3 +25,20 @@ export interface ICoupon {
} }
export type IGetCouponsResponse = IResponse<ICoupon[]>; export type IGetCouponsResponse = IResponse<ICoupon[]>;
export type CreateCouponType = {
code: string;
name: string;
description: string;
type: CouponType;
value: number;
maxDiscount: number | null;
minOrderAmount: number;
maxUses: number;
maxUsesPerUser: number | null;
startDate: string | null;
endDate: string | null;
isActive: boolean;
categoryIds?: string[];
foodIds?: string[];
};
+2
View File
@@ -34,6 +34,7 @@ import ShipmentMethodList from '@/pages/shipmentMethod/List'
import CreateShipmentMethod from '@/pages/shipmentMethod/Create' import CreateShipmentMethod from '@/pages/shipmentMethod/Create'
import UpdateShipmentMethod from '@/pages/shipmentMethod/Update' import UpdateShipmentMethod from '@/pages/shipmentMethod/Update'
import CouponsList from '@/pages/coupon/List' import CouponsList from '@/pages/coupon/List'
import CreateCoupon from '@/pages/coupon/Create'
const MainRouter: FC = () => { const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore() const { hasSubMenu } = useSharedStore()
@@ -83,6 +84,7 @@ const MainRouter: FC = () => {
<Route path={Pages.shipment_methods.update + ':id'} element={<UpdateShipmentMethod />} /> <Route path={Pages.shipment_methods.update + ':id'} element={<UpdateShipmentMethod />} />
<Route path={Pages.coupons.list} element={<CouponsList />} /> <Route path={Pages.coupons.list} element={<CouponsList />} />
<Route path={Pages.coupons.add} element={<CreateCoupon />} />
</Routes> </Routes>
</div> </div>
</div> </div>