From d8b6f2a54f50d73c9f28f0d360124be0852e07cc Mon Sep 17 00:00:00 2001 From: hossein taromi Date: Sat, 30 Aug 2025 17:17:04 +0330 Subject: [PATCH] feat(discount-codes): add discount codes pages, routes, and API constants --- src/App.tsx | 9 + src/components/layout/Sidebar.tsx | 6 + src/constant/routes.ts | 7 + src/pages/discount-codes/core/_hooks.ts | 86 ++++ src/pages/discount-codes/core/_models.ts | 90 +++++ src/pages/discount-codes/core/_requests.ts | 70 ++++ .../DiscountCodeFormPage.tsx | 376 ++++++++++++++++++ .../DiscountCodesListPage.tsx | 198 +++++++++ src/utils/query-key.ts | 7 + 9 files changed, 849 insertions(+) create mode 100644 src/pages/discount-codes/core/_hooks.ts create mode 100644 src/pages/discount-codes/core/_models.ts create mode 100644 src/pages/discount-codes/core/_requests.ts create mode 100644 src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx create mode 100644 src/pages/discount-codes/discount-codes-list/DiscountCodesListPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 4ee1c81..d6a0d57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,10 @@ const ProductOptionFormPage = lazy(() => import('./pages/product-options/product const CategoriesListPage = lazy(() => import('./pages/categories/categories-list/CategoriesListPage')); const CategoryFormPage = lazy(() => import('./pages/categories/category-form/CategoryFormPage')); +// Discount Codes Pages +const DiscountCodesListPage = lazy(() => import('./pages/discount-codes/discount-codes-list/DiscountCodesListPage')); +const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-code-form/DiscountCodeFormPage')); + // Products Pages const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage')); const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage')); @@ -109,6 +113,11 @@ const AppRoutes = () => { } /> } /> + {/* Discount Codes Routes */} + } /> + } /> + } /> + {/* Landing Hero Route */} } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 248d6dd..f0c016f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { Package, FolderOpen, Sliders, + BadgePercent, X } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; @@ -51,6 +52,11 @@ const menuItems: MenuItem[] = [ icon: Sliders, path: '/product-options', }, + { + title: 'کدهای تخفیف', + icon: BadgePercent, + path: '/discount-codes', + }, ] }, { diff --git a/src/constant/routes.ts b/src/constant/routes.ts index 7ad3f25..7160814 100644 --- a/src/constant/routes.ts +++ b/src/constant/routes.ts @@ -80,4 +80,11 @@ export const API_ROUTES = { // Landing Hero APIs GET_LANDING_HERO: "api/v1/settings/landing/hero", UPDATE_LANDING_HERO: "api/v1/admin/settings/landing/hero", + + // Discount Codes APIs + GET_DISCOUNT_CODES: "api/v1/admin/discount/", + GET_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`, + CREATE_DISCOUNT_CODE: "api/v1/admin/discount/", + UPDATE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`, + DELETE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`, }; diff --git a/src/pages/discount-codes/core/_hooks.ts b/src/pages/discount-codes/core/_hooks.ts new file mode 100644 index 0000000..0f3addc --- /dev/null +++ b/src/pages/discount-codes/core/_hooks.ts @@ -0,0 +1,86 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import toast from "react-hot-toast"; +import { + getDiscountCodes, + getDiscountCode, + createDiscountCode, + updateDiscountCode, + deleteDiscountCode, +} from "./_requests"; +import { + CreateDiscountCodeRequest, + UpdateDiscountCodeRequest, + DiscountCodeFilters, +} from "./_models"; + +export const useDiscountCodes = (filters?: DiscountCodeFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODES, filters], + queryFn: () => getDiscountCodes(filters), + }); +}; + +export const useDiscountCode = (id: string) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODE, id], + queryFn: () => getDiscountCode(id), + enabled: !!id, + }); +}; + +export const useCreateDiscountCode = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: CreateDiscountCodeRequest) => + createDiscountCode(payload), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODES], + }); + toast.success("کد تخفیف با موفقیت ایجاد شد"); + }, + onError: (error: any) => { + toast.error(error?.message || "خطا در ایجاد کد تخفیف"); + }, + }); +}; + +export const useUpdateDiscountCode = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: UpdateDiscountCodeRequest) => + updateDiscountCode(payload.id.toString(), payload), + onSuccess: (data: any) => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODES], + }); + if (data?.id) { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODE, data.id.toString()], + }); + } + toast.success("کد تخفیف با موفقیت به‌روزرسانی شد"); + }, + onError: (error: any) => { + toast.error(error?.message || "خطا در به‌روزرسانی کد تخفیف"); + }, + }); +}; + +export const useDeleteDiscountCode = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteDiscountCode(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_CODES], + }); + toast.success("کد تخفیف با موفقیت حذف شد"); + }, + onError: (error: any) => { + toast.error(error?.message || "خطا در حذف کد تخفیف"); + }, + }); +}; + diff --git a/src/pages/discount-codes/core/_models.ts b/src/pages/discount-codes/core/_models.ts new file mode 100644 index 0000000..a91b99f --- /dev/null +++ b/src/pages/discount-codes/core/_models.ts @@ -0,0 +1,90 @@ +export type DiscountCodeType = "percentage" | "fixed"; + +export type DiscountApplicationLevel = + | "invoice" + | "category" + | "product" + | "shipping"; + +export type DiscountStatus = "active" | "inactive"; + +export interface DiscountUserRestrictions { + user_ids?: number[]; + user_group?: string; + new_users_only?: boolean; + loyal_users_only?: boolean; +} + +export interface DiscountMeta { + [key: string]: string | number | boolean | null; +} + +export interface DiscountCode { + id: number; + code: string; + name: string; + description?: string; + type: DiscountCodeType; + value: number; + status: DiscountStatus; + application_level: DiscountApplicationLevel; + min_purchase_amount?: number; + max_discount_amount?: number; + usage_limit?: number; + user_usage_limit?: number; + single_use?: boolean; + valid_from?: string; + valid_to?: string; + user_restrictions?: DiscountUserRestrictions; + meta?: DiscountMeta; + created_at?: string; + updated_at?: string; +} + +export interface DiscountCodeFilters { + page?: number; + limit?: number; + status?: DiscountStatus; + type?: DiscountCodeType; + application_level?: DiscountApplicationLevel; + code?: string; + active_only?: boolean; +} + +export interface CreateDiscountCodeRequest { + code: string; + name: string; + description?: string; + type: DiscountCodeType; + value: number; + status: DiscountStatus; + application_level: DiscountApplicationLevel; + min_purchase_amount?: number; + max_discount_amount?: number; + usage_limit?: number; + user_usage_limit?: number; + single_use?: boolean; + valid_from?: string; + valid_to?: string; + user_restrictions?: DiscountUserRestrictions; + meta?: DiscountMeta; +} + +export interface UpdateDiscountCodeRequest + extends Partial { + id: number; +} + +export interface PaginatedDiscountCodesResponse { + discount_codes: DiscountCode[]; + total: number; + page: number; + limit: number; + total_pages: number; +} + +export type Response = { + data: T; + message?: string; + success?: boolean; +}; diff --git a/src/pages/discount-codes/core/_requests.ts b/src/pages/discount-codes/core/_requests.ts new file mode 100644 index 0000000..d2bb6e5 --- /dev/null +++ b/src/pages/discount-codes/core/_requests.ts @@ -0,0 +1,70 @@ +import { + APIUrlGenerator, + httpGetRequest, + httpPostRequest, + httpPutRequest, + httpDeleteRequest, +} from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + CreateDiscountCodeRequest, + UpdateDiscountCodeRequest, + DiscountCode, + DiscountCodeFilters, + PaginatedDiscountCodesResponse, +} from "./_models"; + +export const getDiscountCodes = async (filters?: DiscountCodeFilters) => { + const queryParams: Record = {}; + + if (filters?.page) queryParams.page = filters.page; + if (filters?.limit) queryParams.limit = filters.limit; + if (filters?.status) queryParams.status = filters.status; + if (filters?.type) queryParams.type = filters.type; + if (filters?.application_level) + queryParams.application_level = filters.application_level; + if (filters?.code) queryParams.code = filters.code; + if (typeof filters?.active_only === "boolean") + queryParams.active_only = filters.active_only; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_DISCOUNT_CODES, queryParams) + ); + + return response.data.discount_codes || []; +}; + +export const getDiscountCode = async (id: string) => { + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_DISCOUNT_CODE(id)) + ); + return response.data as unknown as DiscountCode; +}; + +export const createDiscountCode = async ( + payload: CreateDiscountCodeRequest +) => { + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.CREATE_DISCOUNT_CODE), + payload + ); + return response.data; +}; + +export const updateDiscountCode = async ( + id: string, + payload: UpdateDiscountCodeRequest +) => { + const response = await httpPutRequest( + APIUrlGenerator(API_ROUTES.UPDATE_DISCOUNT_CODE(id)), + payload + ); + return response.data; +}; + +export const deleteDiscountCode = async (id: string) => { + const response = await httpDeleteRequest<{ message: string }>( + APIUrlGenerator(API_ROUTES.DELETE_DISCOUNT_CODE(id)) + ); + return response.data; +}; diff --git a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx new file mode 100644 index 0000000..e2dfbfa --- /dev/null +++ b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx @@ -0,0 +1,376 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '../core/_hooks'; +import { CreateDiscountCodeRequest } from '../core/_models'; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { FormHeader, PageContainer, Label, SectionTitle } from '../../../components/ui/Typography'; +import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from 'lucide-react'; + +const schema = yup.object({ + code: yup.string().required('کد الزامی است'), + name: yup.string().required('نام الزامی است'), + description: yup.string().nullable(), + type: yup.mixed<'percentage' | 'fixed'>().oneOf(['percentage', 'fixed']).required('نوع الزامی است'), + value: yup.number().typeError('مقدار نامعتبر است').required('مقدار الزامی است').min(0), + status: yup.mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']).required('وضعیت الزامی است'), + application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping'>().oneOf(['invoice', 'category', 'product', 'shipping']).required('سطح اعمال الزامی است'), + min_purchase_amount: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(), + max_discount_amount: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(), + usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(), + user_usage_limit: yup.number().transform((v, o) => o === '' ? undefined : v).nullable(), + single_use: yup.boolean().default(false), + valid_from: yup.string().nullable(), + valid_to: yup.string().nullable(), +}); + +const formatDateTimeLocal = (dateString?: string): string => { + if (!dateString) return ''; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return ''; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } catch { + return ''; + } +}; + +const DiscountCodeFormPage = () => { + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = !!id; + + const { data: dc, isLoading: dcLoading } = useDiscountCode(id || ''); + const { mutate: create, isPending: creating } = useCreateDiscountCode(); + const { mutate: update, isPending: updating } = useUpdateDiscountCode(); + + const { register, handleSubmit, formState: { errors, isValid }, reset } = useForm({ + resolver: yupResolver(schema), + mode: 'onChange', + defaultValues: { status: 'active', type: 'percentage', application_level: 'invoice', single_use: false } + }); + + useEffect(() => { + if (isEdit && dc) { + reset({ + code: dc.code, + name: dc.name, + description: dc.description, + type: dc.type, + value: dc.value, + status: dc.status, + application_level: dc.application_level, + min_purchase_amount: dc.min_purchase_amount, + max_discount_amount: dc.max_discount_amount, + usage_limit: dc.usage_limit, + user_usage_limit: dc.user_usage_limit, + single_use: dc.single_use, + valid_from: formatDateTimeLocal(dc.valid_from), + valid_to: formatDateTimeLocal(dc.valid_to), + user_restrictions: dc.user_restrictions, + meta: dc.meta, + }); + } + }, [isEdit, dc, reset]); + + const onSubmit = (data: CreateDiscountCodeRequest) => { + if (isEdit && id) { + update({ id: parseInt(id), ...data }, { onSuccess: () => navigate('/discount-codes') }); + } else { + create(data, { onSuccess: () => navigate('/discount-codes') }); + } + }; + + if (isEdit && dcLoading) return ; + const isLoading = creating || updating; + + return ( + + navigate('/discount-codes')} className="flex items-center gap-2"> + + بازگشت + + } + /> + +
+
+ {/* اطلاعات اصلی */} +
+
+
+
+ +
+ اطلاعات اصلی کد تخفیف +
+
+
+
+ + +
+ +