feat(discount-codes): add discount codes pages, routes, and API constants

This commit is contained in:
hossein taromi 2025-08-30 17:17:04 +03:30
parent 8b685c8668
commit d8b6f2a54f
9 changed files with 849 additions and 0 deletions

View File

@ -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 />} />

View File

@ -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',
},
]
},
{

View File

@ -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}/`,
};

View File

@ -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 || "خطا در حذف کد تخفیف");
},
});
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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",
};