feat(discount-codes): add discount codes pages, routes, and API constants
This commit is contained in:
parent
8b685c8668
commit
d8b6f2a54f
|
|
@ -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 = () => {
|
|||
<Route path="categories/create" element={<CategoryFormPage />} />
|
||||
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
|
||||
|
||||
{/* Discount Codes Routes */}
|
||||
<Route path="discount-codes" element={<DiscountCodesListPage />} />
|
||||
<Route path="discount-codes/create" element={<DiscountCodeFormPage />} />
|
||||
<Route path="discount-codes/:id/edit" element={<DiscountCodeFormPage />} />
|
||||
|
||||
{/* Landing Hero Route */}
|
||||
<Route path="landing-hero" element={<HeroSliderPage />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}/`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 || "خطا در حذف کد تخفیف");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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<CreateDiscountCodeRequest> {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PaginatedDiscountCodesResponse {
|
||||
discount_codes: DiscountCode[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export type Response<T> = {
|
||||
data: T;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
};
|
||||
|
|
@ -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<string, string | number | boolean | null> = {};
|
||||
|
||||
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<PaginatedDiscountCodesResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_DISCOUNT_CODES, queryParams)
|
||||
);
|
||||
|
||||
return response.data.discount_codes || [];
|
||||
};
|
||||
|
||||
export const getDiscountCode = async (id: string) => {
|
||||
const response = await httpGetRequest<DiscountCode>(
|
||||
APIUrlGenerator(API_ROUTES.GET_DISCOUNT_CODE(id))
|
||||
);
|
||||
return response.data as unknown as DiscountCode;
|
||||
};
|
||||
|
||||
export const createDiscountCode = async (
|
||||
payload: CreateDiscountCodeRequest
|
||||
) => {
|
||||
const response = await httpPostRequest<DiscountCode>(
|
||||
APIUrlGenerator(API_ROUTES.CREATE_DISCOUNT_CODE),
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateDiscountCode = async (
|
||||
id: string,
|
||||
payload: UpdateDiscountCodeRequest
|
||||
) => {
|
||||
const response = await httpPutRequest<DiscountCode>(
|
||||
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;
|
||||
};
|
||||
|
|
@ -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<CreateDiscountCodeRequest>({
|
||||
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 <LoadingSpinner />;
|
||||
const isLoading = creating || updating;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<FormHeader
|
||||
title={isEdit ? 'ویرایش کد تخفیف' : 'ایجاد کد تخفیف'}
|
||||
subtitle="ایجاد و مدیریت کدهای تخفیف برای فروشگاه"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={() => navigate('/discount-codes')} className="flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* اطلاعات اصلی */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Tag className="h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<SectionTitle>اطلاعات اصلی کد تخفیف</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="کد تخفیف"
|
||||
type="text"
|
||||
placeholder="مثال: SAVE20"
|
||||
error={errors.code?.message}
|
||||
{...register('code')}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label="نام نمایشی"
|
||||
type="text"
|
||||
placeholder="مثال: تخفیف ۲۰ درصدی"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
<div className="lg:col-span-2">
|
||||
<Label htmlFor="description">توضیحات</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
placeholder="توضیحات کامل درباره این کد تخفیف..."
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none h-24 transition-colors ${errors.description ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 dark:border-gray-600'} dark:bg-gray-700 dark:text-gray-100`}
|
||||
{...register('description' as const)}
|
||||
/>
|
||||
{errors.description && <p className="text-sm text-red-600 dark:text-red-400 mt-1">{errors.description.message as string}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تنظیمات تخفیف */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<Settings className="h-5 w-5 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<SectionTitle>تنظیمات تخفیف</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>نوع تخفیف</Label>
|
||||
<select className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors" {...register('type')}>
|
||||
<option value="percentage">درصدی</option>
|
||||
<option value="fixed">مبلغ ثابت</option>
|
||||
</select>
|
||||
{errors.type && <p className="text-sm text-red-600 dark:text-red-400">{errors.type.message as string}</p>}
|
||||
</div>
|
||||
<Input
|
||||
label="مقدار تخفیف"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="20"
|
||||
error={errors.value?.message as string}
|
||||
{...register('value')}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>وضعیت</Label>
|
||||
<select className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors" {...register('status')}>
|
||||
<option value="active">فعال</option>
|
||||
<option value="inactive">غیرفعال</option>
|
||||
</select>
|
||||
{errors.status && <p className="text-sm text-red-600 dark:text-red-400">{errors.status.message as string}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>سطح اعمال</Label>
|
||||
<select className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors" {...register('application_level')}>
|
||||
<option value="invoice">کل سبد خرید</option>
|
||||
<option value="category">دستهبندی خاص</option>
|
||||
<option value="product">محصول خاص</option>
|
||||
<option value="shipping">هزینه ارسال</option>
|
||||
</select>
|
||||
{errors.application_level && <p className="text-sm text-red-600 dark:text-red-400">{errors.application_level.message as string}</p>}
|
||||
</div>
|
||||
<Input
|
||||
label="حداقل مبلغ خرید"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="100000"
|
||||
error={errors.min_purchase_amount?.message as string}
|
||||
{...register('min_purchase_amount')}
|
||||
/>
|
||||
<Input
|
||||
label="حداکثر مبلغ تخفیف"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="50000"
|
||||
error={errors.max_discount_amount?.message as string}
|
||||
{...register('max_discount_amount')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Input
|
||||
label="حداکثر تعداد استفاده"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
error={errors.usage_limit?.message as string}
|
||||
{...register('usage_limit')}
|
||||
/>
|
||||
<Input
|
||||
label="حداکثر استفاده هر کاربر"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
error={errors.user_usage_limit?.message as string}
|
||||
{...register('user_usage_limit')}
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-8">
|
||||
<input
|
||||
id="single_use"
|
||||
type="checkbox"
|
||||
className="h-5 w-5 text-blue-600 border-2 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
{...register('single_use')}
|
||||
/>
|
||||
<Label htmlFor="single_use" className="text-base">تکبار مصرف</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بازه زمانی */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-300" />
|
||||
</div>
|
||||
<SectionTitle>بازه زمانی اعتبار</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="شروع اعتبار"
|
||||
type="datetime-local"
|
||||
error={errors.valid_from?.message as string}
|
||||
{...register('valid_from')}
|
||||
/>
|
||||
<Input
|
||||
label="پایان اعتبار"
|
||||
type="datetime-local"
|
||||
error={errors.valid_to?.message as string}
|
||||
{...register('valid_to')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* محدودیتهای کاربری */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg">
|
||||
<Users className="h-5 w-5 text-orange-600 dark:text-orange-300" />
|
||||
</div>
|
||||
<SectionTitle>محدودیتهای کاربری</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="گروه کاربری"
|
||||
type="text"
|
||||
placeholder="مثال: loyal"
|
||||
{...register('user_restrictions.user_group')}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Label>محدودیتهای خاص</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="new_users_only"
|
||||
type="checkbox"
|
||||
className="h-5 w-5 text-blue-600 border-2 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
{...register('user_restrictions.new_users_only')}
|
||||
/>
|
||||
<Label htmlFor="new_users_only" className="text-base">فقط کاربران جدید</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="loyal_users_only"
|
||||
type="checkbox"
|
||||
className="h-5 w-5 text-blue-600 border-2 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
{...register('user_restrictions.loyal_users_only')}
|
||||
/>
|
||||
<Label htmlFor="loyal_users_only" className="text-base">فقط کاربران وفادار</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات تکمیلی */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-100 dark:bg-teal-900 rounded-lg">
|
||||
<Info className="h-5 w-5 text-teal-600 dark:text-teal-300" />
|
||||
</div>
|
||||
<SectionTitle>اطلاعات تکمیلی</SectionTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="نام کمپین"
|
||||
type="text"
|
||||
placeholder="مثال: summer_sale"
|
||||
{...register('meta.campaign')}
|
||||
/>
|
||||
<Input
|
||||
label="دستهبندی کمپین"
|
||||
type="text"
|
||||
placeholder="مثال: seasonal"
|
||||
{...register('meta.category')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمههای اکشن */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/discount-codes')}
|
||||
className="sm:order-1"
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isLoading}
|
||||
disabled={!isValid}
|
||||
className="sm:order-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800"
|
||||
>
|
||||
{isEdit ? 'بهروزرسانی کد تخفیف' : 'ایجاد کد تخفیف'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscountCodeFormPage;
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
|
||||
import { DiscountCode } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react';
|
||||
|
||||
const ListSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نوع</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">بازه زمانی</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 6 }).map((__, j) => (
|
||||
<td key={j} className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DiscountCodesListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState({ code: '' });
|
||||
|
||||
const { data: discountCodes, isLoading, error } = useDiscountCodes(filters);
|
||||
const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode();
|
||||
|
||||
const handleCreate = () => navigate('/discount-codes/create');
|
||||
const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`);
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteId) {
|
||||
deleteDiscount(deleteId, { onSuccess: () => setDeleteId(null) });
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری کدهای تخفیف</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<BadgePercent className="h-6 w-6" />
|
||||
مدیریت کدهای تخفیف
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">ایجاد و مدیریت کدهای تخفیف</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
title="کد تخفیف جدید"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو بر اساس کد..."
|
||||
value={filters.code}
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, code: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : !discountCodes || discountCodes.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="text-center py-12">
|
||||
<Ticket className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ کد تخفیفی یافت نشد</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">برای شروع یک کد تخفیف ایجاد کنید</p>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
ایجاد کد تخفیف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">مقدار</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">سطح</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">بازه زمانی</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{(discountCodes || []).map((dc: DiscountCode) => (
|
||||
<tr key={dc.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{dc.code}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{dc.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{dc.type === 'percentage' ? `${dc.value}%` : `${dc.value} تومان`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{dc.application_level === 'invoice' ? 'کل سبد' :
|
||||
dc.application_level === 'category' ? 'دستهبندی' :
|
||||
dc.application_level === 'product' ? 'محصول' : 'ارسال'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${dc.status === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{dc.status === 'active' ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{dc.valid_from ? new Date(dc.valid_from).toLocaleDateString('fa-IR') : '-'}
|
||||
{' '}تا{' '}
|
||||
{dc.valid_to ? new Date(dc.valid_to).toLocaleDateString('fa-IR') : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(dc.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(dc.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف">
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست.</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
|
||||
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscountCodesListPage;
|
||||
|
||||
|
||||
|
||||
|
|
@ -71,4 +71,11 @@ export const QUERY_KEYS = {
|
|||
// Landing Hero
|
||||
GET_LANDING_HERO: "get_landing_hero",
|
||||
UPDATE_LANDING_HERO: "update_landing_hero",
|
||||
|
||||
// Discount Codes
|
||||
GET_DISCOUNT_CODES: "get_discount_codes",
|
||||
GET_DISCOUNT_CODE: "get_discount_code",
|
||||
CREATE_DISCOUNT_CODE: "create_discount_code",
|
||||
UPDATE_DISCOUNT_CODE: "update_discount_code",
|
||||
DELETE_DISCOUNT_CODE: "delete_discount_code",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue