Crud create and variant category

This commit is contained in:
hamid zarghami
2025-09-21 14:30:13 +03:30
parent 1457afce0b
commit f731f8f73d
23 changed files with 903 additions and 29 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
VITE_BASE_URL = 'https://shop-api.dev.danakcorp.com'
# VITE_BASE_URL = 'https://shop-api.dev.danakcorp.com'
VITE_BASE_URL = 'http://192.168.1.122:4000'
VITE_TOKEN_NAME = 'sh_admin_token'
VITE_REFRESH_TOKEN_NAME = 'sh_admin_refresh_token'
+3 -3
View File
@@ -26,9 +26,9 @@ const DefaulModal: FC<Props> = (props: Props) => {
{
props.open && (
<Fragment>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[60] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-hidden fixed inset-0 z-[60] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full modalGlass2 outline-none focus:outline-none'>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none'>
{
@@ -46,7 +46,7 @@ const DefaulModal: FC<Props> = (props: Props) => {
</div>
</div>
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 modalGlass inset-0 z-50 '></div>
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/50 backdrop-blur-md inset-0 z-50 '></div>
</Fragment>
)
}
+1 -1
View File
@@ -31,7 +31,7 @@ const Select: FC<Props> = (props: Props) => {
)}>
{
props.placeholder &&
<option value="" disabled selected>{props.placeholder}</option>
<option value="" disabled>{props.placeholder}</option>
}
{
props.items?.map((item) => {
+1
View File
@@ -121,5 +121,6 @@ export const Pages = {
create: "/category/create",
update: "/category/update/",
detail: "/category/",
variant: "/category/variant/",
},
};
+9
View File
@@ -18,3 +18,12 @@ export type ErrorType = Error & {
};
};
};
export interface IGeneralError {
status: number;
success: boolean;
error: {
message: string;
details: string[];
};
}
+13
View File
@@ -1,4 +1,5 @@
import { twMerge } from "tailwind-merge";
import type { IGeneralError } from "./types";
/**
* Concatenates truthy classes into a space-separated string.
@@ -9,3 +10,15 @@ import { twMerge } from "tailwind-merge";
export const clx = (...classes: (string | boolean | undefined)[]): string => {
return twMerge(classes.filter(Boolean).join(" "));
};
export const extractErrorMessage = (
error: Error | unknown,
fallbackMessage: string = "خطایی رخ داده است"
): string => {
try {
const axiosError = error as { response?: { data: IGeneralError } };
return axiosError?.response?.data?.error?.details?.[0] || fallbackMessage;
} catch {
return fallbackMessage;
}
};
+5 -4
View File
@@ -8,6 +8,7 @@ import Textarea from '../../components/Textarea'
import UploadBox from '../../components/UploadBox'
import Select from '../../components/Select'
import { type CategoryType, type CategoryTreeNode } from './types/Types'
import { CategoryThemeEnum } from './enum/Enum'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { useCreateCategory, useGetCategories, useSingleUpload } from './hooks/useCategoryData'
@@ -149,10 +150,10 @@ const CreateCategory: FC = () => {
label='تم دسته'
placeholder='انتخاب تم دسته'
items={[
{ value: 'Color', label: 'رنگی' },
{ value: 'Monochrome', label: 'تک‌رنگ' },
{ value: 'Gradient', label: 'گرادیانت' },
{ value: 'Minimal', label: 'مینیمال' },
{ value: CategoryThemeEnum.Sized, label: 'سایز' },
{ value: CategoryThemeEnum.Colored, label: 'رنگ' },
{ value: CategoryThemeEnum.Meterage, label: 'متراژ' },
{ value: CategoryThemeEnum.No_color_No_sized, label: 'بدون تنوع' },
]}
value={formik.values.theme}
onChange={(e) => formik.setFieldValue('theme', e.target.value)}
+22 -6
View File
@@ -1,16 +1,24 @@
import { type FC, type JSX, useState } from 'react'
import Td from '../../components/Td'
import { ArrowDown2, ArrowLeft2, Edit } from 'iconsax-react'
import { ArrowDown2, ArrowLeft2, Edit, Lock } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { useGetCategories } from './hooks/useCategoryData'
import { useGetCategories, useDeleteCategory } from './hooks/useCategoryData'
import { type CategoryTreeNode } from './types/Types'
import { Pages } from '../../config/Pages'
import Button from '../../components/Button'
import TrashWithConfrim from '../../components/TrashWithConfrim'
const CategoryList: FC = () => {
const { data, isLoading } = useGetCategories();
const { data, isLoading, refetch } = useGetCategories();
const deleteCategoryMutation = useDeleteCategory();
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const handleDeleteCategory = async (categoryId: string) => {
await deleteCategoryMutation.mutateAsync(categoryId);
refetch()
};
const toggleExpanded = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
@@ -55,12 +63,20 @@ const CategoryList: FC = () => {
</div>
</Td>
<Td text={category.description} />
<Td text={category.parent || 'اصلی'} />
<Td text=''>
{category.leaf ? <Link to={`${Pages.category.variant}${category._id}`}>
<Button className='h-7 text-xs w-fit px-4' label='افزودن تنوع' />
</Link> : <Lock size={20} color='#8C90A3' />}
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Link to={`${Pages.category.update}${category._id}`} state={{ from: 'list' }}>
<Edit size={20} color='#8C90A3' />
</Link>
<TrashWithConfrim
onDelete={() => handleDeleteCategory(category._id)}
isLoading={deleteCategoryMutation.isPending}
/>
{category.children && category.children.length > 0 && (
<button
onClick={() => toggleExpanded(category._id)}
@@ -86,7 +102,7 @@ const CategoryList: FC = () => {
// اضافه کردن خود دسته‌بندی
result.push(renderCategoryRow(category, level));
// اگر دسته‌بندی expand شده باشد، فرزندانش را اضافه کن
// اگر دسته‌بندی expand شده باشد، فرزندانش را به صورت بازگشتی اضافه کن
if (expandedCategories.has(category._id) && category.children && category.children.length > 0) {
result.push(...renderCategories(category.children, level + 1));
}
@@ -110,7 +126,7 @@ const CategoryList: FC = () => {
<Td text={'عنوان انگلیسی'} />
<Td text={'تصویر'} />
<Td text={'توضیحات'} />
<Td text={'والد'} />
<Td text={'تنوع'} />
<Td text={'عملیات'} />
</tr>
</thead>
+10 -6
View File
@@ -7,6 +7,7 @@ import Textarea from '../../components/Textarea'
import UploadBox from '../../components/UploadBox'
import Select from '../../components/Select'
import { type CategoryType, type CategoryTreeNode } from './types/Types'
import { CategoryThemeEnum } from './enum/Enum'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { useUpdateCategory, useGetCategories, useGetCategoryById, useSingleUpload } from './hooks/useCategoryData'
@@ -23,6 +24,7 @@ const UpdateCategory: FC = () => {
const [isActive, setIsActive] = useState(true)
const [hasIconChanged, setHasIconChanged] = useState(false)
const [hasImageChanged, setHasImageChanged] = useState(false)
const [isDataLoaded, setIsDataLoaded] = useState(false)
const singleUpload = useSingleUpload()
const updateCategoryMutation = useUpdateCategory()
@@ -78,7 +80,7 @@ const UpdateCategory: FC = () => {
// بارگذاری داده‌های دسته برای ویرایش
useEffect(() => {
if (getCategoryById.data?.results?.data) {
if (getCategoryById.data?.results?.data && !isDataLoaded) {
const categoryData = getCategoryById.data.results.data
formik.setValues({
title_fa: categoryData.title_fa || '',
@@ -90,8 +92,10 @@ const UpdateCategory: FC = () => {
theme: categoryData.theme || '',
})
setIsActive(!categoryData.deleted)
setIsDataLoaded(true)
}
}, [getCategoryById.data, formik])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getCategoryById.data, isDataLoaded])
const categoriesData: CategoryTreeNode[] = getCategories.data?.results?.data || []
@@ -198,10 +202,10 @@ const UpdateCategory: FC = () => {
label='تم دسته'
placeholder='انتخاب تم دسته'
items={[
{ value: 'Color', label: 'رنگی' },
{ value: 'Monochrome', label: 'تک‌رنگ' },
{ value: 'Gradient', label: 'گرادیانت' },
{ value: 'Minimal', label: 'مینیمال' },
{ value: CategoryThemeEnum.Sized, label: 'سایز' },
{ value: CategoryThemeEnum.Colored, label: 'رنگ' },
{ value: CategoryThemeEnum.Meterage, label: 'متراژ' },
{ value: CategoryThemeEnum.No_color_No_sized, label: 'بدون تنوع' },
]}
value={formik.values.theme}
onChange={(e) => formik.setFieldValue('theme', e.target.value)}
+146
View File
@@ -0,0 +1,146 @@
import { type FC } from 'react'
import { useParams } from 'react-router-dom'
import { CategoryThemeEnum } from './enum/Enum'
import TitleLine from '../../components/TitleLine'
import { VariantSizesList, VariantColorsList, VariantMeteragesList, VariantModal } from './components'
import { useVariantLogic } from './hooks/useVariantLogic'
const Variant: FC = () => {
const { id } = useParams()
const {
category,
theme,
categoryVariants,
isLoading,
isModalOpen,
newVariant,
openModal,
closeModal,
setNewVariant,
addVariant,
deleteVariant,
isAddingLoading,
isUpdatingLoading
} = useVariantLogic({ id })
if (isLoading) {
return <div>در حال بارگذاری...</div>
}
if (!category) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<TitleLine title="مدیریت Variant دسته‌بندی" />
<div className="mt-4 space-y-2">
<h1 className="text-2xl font-bold text-gray-900">
دستهبندی یافت نشد
</h1>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex flex-col items-center justify-center py-12 px-6 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
<div className="text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-gray-200 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-1">دادهای یافت نشد</h3>
<p className="text-gray-500 mb-4">امکان ساخت variant جدید وجود ندارد</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
const renderVariants = () => {
switch (theme) {
case CategoryThemeEnum.Sized:
return (
<VariantSizesList
sizes={categoryVariants.sizes || []}
onAddClick={openModal}
onDelete={(index) => deleteVariant('sizes', index)}
isLoading={isUpdatingLoading}
/>
)
case CategoryThemeEnum.Colored:
return (
<VariantColorsList
colors={categoryVariants.colors || []}
onAddClick={openModal}
onDelete={(index) => deleteVariant('colors', index)}
isLoading={isUpdatingLoading}
/>
)
case CategoryThemeEnum.Meterage:
return (
<VariantMeteragesList
meterages={categoryVariants.meterages || []}
onAddClick={openModal}
onDelete={(index) => deleteVariant('meterages', index)}
isLoading={isUpdatingLoading}
/>
)
case CategoryThemeEnum.No_color_No_sized:
return (
<div className="p-4 border border-gray-200 rounded-lg bg-gray-50 text-center">
<span className="text-gray-600">این دستهبندی variant ندارد</span>
</div>
)
default:
return (
<div className="p-4 border border-yellow-200 rounded-lg bg-yellow-50 text-center">
<span className="text-yellow-800">تم ناشناخته: {theme}</span>
</div>
)
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<TitleLine title="مدیریت Variant دسته‌بندی" />
<div className="mt-4 space-y-2">
<h1 className="text-2xl font-bold text-gray-900">
{category.title_fa}
</h1>
<p className="text-gray-600">
تم: <span className="font-medium">{category.theme}</span>
</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{renderVariants()}
</div>
</div>
{theme && (
<VariantModal
isOpen={isModalOpen}
onClose={closeModal}
theme={theme}
newVariant={newVariant}
onVariantChange={setNewVariant}
onAdd={addVariant}
isLoading={isAddingLoading || isUpdatingLoading}
/>
)}
</div>
)
}
export default Variant
@@ -0,0 +1,58 @@
import { type FC } from 'react'
import { type ColorVariantType } from '../types/Types'
import Button from '../../../components/Button'
import TrashWithConfrim from '../../../components/TrashWithConfrim'
import VariantEmptyState from './VariantEmptyState'
interface VariantColorsListProps {
colors: ColorVariantType[]
onAddClick: () => void
onDelete: (index: number) => void
isLoading: boolean
}
const VariantColorsList: FC<VariantColorsListProps> = ({
colors,
onAddClick,
onDelete,
isLoading
}) => {
if (!colors || colors.length === 0) {
return <VariantEmptyState type="colors" onAddClick={onAddClick} />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">رنگها:</h3>
<Button
size="sm"
variant="outline"
className="w-auto px-3"
onClick={onAddClick}
>
اضافه کردن رنگ
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{colors.map((color: ColorVariantType, index: number) => (
<div key={color.name} className="p-3 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow flex items-center gap-3 justify-between">
<div className="flex items-center gap-3">
<div
className="w-6 h-6 rounded-full border-2 border-gray-300 shadow-sm"
style={{ backgroundColor: color.hexColor }}
></div>
<span className="text-gray-700 font-medium">{color.name}</span>
</div>
<TrashWithConfrim
onDelete={() => onDelete(index)}
isLoading={isLoading}
/>
</div>
))}
</div>
</div>
)
}
export default VariantColorsList
@@ -0,0 +1,72 @@
import { type FC } from 'react'
import Button from '../../../components/Button'
interface VariantEmptyStateProps {
type: 'sizes' | 'colors' | 'meterages'
onAddClick: () => void
}
const VariantEmptyState: FC<VariantEmptyStateProps> = ({ type, onAddClick }) => {
const getContent = () => {
switch (type) {
case 'sizes':
return {
icon: (
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
),
title: 'هیچ سایزی تعریف نشده',
description: 'برای شروع، سایز جدیدی اضافه کنید',
buttonText: 'اضافه کردن اولین سایز'
}
case 'colors':
return {
icon: (
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z" />
</svg>
),
title: 'هیچ رنگی تعریف نشده',
description: 'برای شروع، رنگ جدیدی اضافه کنید',
buttonText: 'اضافه کردن اولین رنگ'
}
case 'meterages':
return {
icon: (
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
title: 'هیچ متریاژی تعریف نشده',
description: 'برای شروع، متریاژ جدیدی اضافه کنید',
buttonText: 'اضافه کردن اولین متریاژ'
}
}
}
const { icon, title, description, buttonText } = getContent()
return (
<div className="flex flex-col items-center justify-center py-12 px-6 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
<div className="text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-gray-200 rounded-full flex items-center justify-center">
{icon}
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
<p className="text-gray-500 mb-4">{description}</p>
</div>
<Button
variant="primary"
onClick={onAddClick}
className="px-6"
>
{buttonText}
</Button>
</div>
</div>
)
}
export default VariantEmptyState
@@ -0,0 +1,52 @@
import { type FC } from 'react'
import { type VariantType } from '../types/Types'
import Button from '../../../components/Button'
import TrashWithConfrim from '../../../components/TrashWithConfrim'
import VariantEmptyState from './VariantEmptyState'
interface VariantMeteragesListProps {
meterages: VariantType[]
onAddClick: () => void
onDelete: (index: number) => void
isLoading: boolean
}
const VariantMeteragesList: FC<VariantMeteragesListProps> = ({
meterages,
onAddClick,
onDelete,
isLoading
}) => {
if (!meterages || meterages.length === 0) {
return <VariantEmptyState type="meterages" onAddClick={onAddClick} />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">متریاژ:</h3>
<Button
size="sm"
variant="outline"
className="w-auto px-3"
onClick={onAddClick}
>
اضافه کردن متریاژ
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{meterages.map((meterage: VariantType, index: number) => (
<div key={meterage._id} className="p-3 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow flex items-center justify-between">
<span className="text-gray-700">{meterage.value}</span>
<TrashWithConfrim
onDelete={() => onDelete(index)}
isLoading={isLoading}
/>
</div>
))}
</div>
</div>
)
}
export default VariantMeteragesList
@@ -0,0 +1,144 @@
import { type FC } from 'react'
import { CategoryThemeEnum } from '../enum/Enum'
import Button from '../../../components/Button'
import DefaulModal from '../../../components/DefaulModal'
import Input from '../../../components/Input'
interface VariantModalProps {
isOpen: boolean
onClose: () => void
theme: CategoryThemeEnum
newVariant: {
value: string
name: string
hexColor: string
}
onVariantChange: (variant: { value: string; name: string; hexColor: string }) => void
onAdd: () => void
isLoading?: boolean
}
const VariantModal: FC<VariantModalProps> = ({
isOpen,
onClose,
theme,
newVariant,
onVariantChange,
onAdd,
isLoading = false
}) => {
const getModalTitle = () => {
switch (theme) {
case CategoryThemeEnum.Sized:
return 'اضافه کردن سایز جدید'
case CategoryThemeEnum.Colored:
return 'اضافه کردن رنگ جدید'
case CategoryThemeEnum.Meterage:
return 'اضافه کردن متریاژ جدید'
default:
return 'اضافه کردن variant جدید'
}
}
const renderFormContent = () => {
switch (theme) {
case CategoryThemeEnum.Sized:
return (
<Input
label="مقدار سایز"
placeholder="مثال: XL, M, 42"
value={newVariant.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onVariantChange({ ...newVariant, value: e.target.value })}
/>
)
case CategoryThemeEnum.Colored:
return (
<div className="space-y-4">
<Input
label="نام رنگ"
placeholder="مثال: آبی, قرمز"
value={newVariant.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onVariantChange({ ...newVariant, name: e.target.value })}
/>
<div className="space-y-2">
<label className="text-sm">کد رنگ</label>
<div className="flex items-center gap-3">
<input
type="color"
value={newVariant.hexColor}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onVariantChange({ ...newVariant, hexColor: e.target.value })}
className="w-12 h-10 border border-gray-300 rounded cursor-pointer"
/>
<Input
value={newVariant.hexColor}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onVariantChange({ ...newVariant, hexColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
</div>
)
case CategoryThemeEnum.Meterage:
return (
<Input
label="مقدار متریاژ"
placeholder="مثال: 2 متر, 5 سانتی متر"
value={newVariant.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onVariantChange({ ...newVariant, value: e.target.value })}
/>
)
default:
return null
}
}
const isFormValid = () => {
switch (theme) {
case CategoryThemeEnum.Sized:
return !!newVariant.value.trim()
case CategoryThemeEnum.Colored:
return !!newVariant.name.trim()
case CategoryThemeEnum.Meterage:
return !!newVariant.value.trim()
default:
return false
}
}
return (
<DefaulModal
open={isOpen}
close={onClose}
isHeader={true}
title_header={getModalTitle()}
width={500}
>
<div className="space-y-6">
{renderFormContent()}
<div className="flex gap-3 pt-4 border-t border-border">
<Button
variant="outline"
className="flex-1"
onClick={onClose}
>
انصراف
</Button>
<Button
variant="primary"
className="flex-1"
onClick={onAdd}
disabled={!isFormValid() || isLoading}
>
{isLoading ? 'در حال اضافه کردن...' : 'اضافه کردن'}
</Button>
</div>
</div>
</DefaulModal>
)
}
export default VariantModal
@@ -0,0 +1,52 @@
import { type FC } from 'react'
import { type VariantType } from '../types/Types'
import Button from '../../../components/Button'
import TrashWithConfrim from '../../../components/TrashWithConfrim'
import VariantEmptyState from './VariantEmptyState'
interface VariantSizesListProps {
sizes: VariantType[]
onAddClick: () => void
onDelete: (index: number) => void
isLoading: boolean
}
const VariantSizesList: FC<VariantSizesListProps> = ({
sizes,
onAddClick,
onDelete,
isLoading
}) => {
if (!sizes || sizes.length === 0) {
return <VariantEmptyState type="sizes" onAddClick={onAddClick} />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">سایزها:</h3>
<Button
size="sm"
variant="outline"
className="w-auto px-3"
onClick={onAddClick}
>
اضافه کردن سایز
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{sizes.map((size: VariantType, index: number) => (
<div key={size._id} className="p-3 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow flex items-center justify-between">
<span className="text-gray-700">{size.value}</span>
<TrashWithConfrim
onDelete={() => onDelete(index)}
isLoading={isLoading}
/>
</div>
))}
</div>
</div>
)
}
export default VariantSizesList
+5
View File
@@ -3,3 +3,8 @@ export { default as CategoryTree } from "./CategoryTree";
export { default as CategoryInfo } from "./CategoryInfo";
export { default as CategoryBreadcrumb } from "./CategoryBreadcrumb";
export { default as CategorySummary } from "./CategorySummary";
export { default as VariantEmptyState } from "./VariantEmptyState";
export { default as VariantSizesList } from "./VariantSizesList";
export { default as VariantColorsList } from "./VariantColorsList";
export { default as VariantMeteragesList } from "./VariantMeteragesList";
export { default as VariantModal } from "./VariantModal";
+13
View File
@@ -0,0 +1,13 @@
export enum CategoryThemeEnum {
Sized = "Size",
Colored = "Color",
Meterage = "Meterage",
No_color_No_sized = "noColor_noSize",
}
export const enum categoryAttType {
Text = "text",
Select = "select",
Number = "number",
CheckBox = "checkbox",
}
@@ -41,3 +41,29 @@ export const useSingleUpload = () => {
mutationFn: (variables: File) => api.singleUpload(variables),
});
};
export const useGetCategoryVariants = (id: string) => {
return useQuery({
queryKey: ['category', 'variants', id],
queryFn: () => api.getCategoryVariants(id),
enabled: !!id,
});
};
export const useCreateCategoryVariant = () => {
return useMutation({
mutationFn: api.createCategoryVariant,
});
};
export const useUpdateCategoryVariant = () => {
return useMutation({
mutationFn: api.updateCategoryVariant,
});
};
export const useDeleteCategoryVariant = () => {
return useMutation({
mutationFn: api.deleteCategoryVariant,
});
};
@@ -0,0 +1,169 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useGetCategoryVariants, useCreateCategoryVariant, useUpdateCategoryVariant } from './useCategoryData'
import { CategoryThemeEnum } from '../enum/Enum'
import { type VariantType, type ColorVariantType } from '../types/Types'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '../../../helpers/utils'
interface UseVariantLogicProps {
id: string | undefined
}
interface CategoryVariantData {
category: {
categoryVariants: {
sizes?: VariantType[]
colors?: ColorVariantType[]
meterages?: VariantType[]
}
theme: CategoryThemeEnum
title_fa: string
}
}
interface ApiResponse {
results: {
data: CategoryVariantData
}
}
export const useVariantLogic = ({ id }: UseVariantLogicProps) => {
const queryClient = useQueryClient()
const { data, isLoading } = useGetCategoryVariants(id!)
const createVariantMutation = useCreateCategoryVariant()
const updateVariantMutation = useUpdateCategoryVariant()
const [isModalOpen, setIsModalOpen] = useState(false)
const [newVariant, setNewVariant] = useState({
value: '',
name: '',
hexColor: '#000000'
})
const categoryData = data as ApiResponse | undefined
const category = categoryData?.results?.data?.category
const theme = category?.theme
const categoryVariants = category?.categoryVariants || {
sizes: [],
colors: [],
meterages: []
}
const hasExistingVariants =
(categoryVariants.sizes && categoryVariants.sizes.length > 0) ||
(categoryVariants.colors && categoryVariants.colors.length > 0) ||
(categoryVariants.meterages && categoryVariants.meterages.length > 0)
const resetModal = () => {
setIsModalOpen(false)
setNewVariant({ value: '', name: '', hexColor: '#000000' })
}
const openModal = () => setIsModalOpen(true)
const closeModal = () => resetModal()
const addVariant = async () => {
if (!id || !categoryData) return
let variantData
switch (theme) {
case CategoryThemeEnum.Sized:
variantData = {
id,
theme: CategoryThemeEnum.Sized,
sizes: hasExistingVariants
? [...(categoryVariants.sizes || []), { _id: Date.now(), value: newVariant.value }]
: [{ _id: Date.now(), value: newVariant.value }]
}
break
case CategoryThemeEnum.Colored:
variantData = {
id,
theme: CategoryThemeEnum.Colored,
colors: hasExistingVariants
? [...(categoryVariants.colors || []), { name: newVariant.name, hexColor: newVariant.hexColor }]
: [{ name: newVariant.name, hexColor: newVariant.hexColor }]
}
break
case CategoryThemeEnum.Meterage:
variantData = {
id,
theme: CategoryThemeEnum.Meterage,
meterages: hasExistingVariants
? [...(categoryVariants.meterages || []), { _id: Date.now(), value: newVariant.value }]
: [{ _id: Date.now(), value: newVariant.value }]
}
break
default:
return
}
const mutation = hasExistingVariants ? updateVariantMutation : createVariantMutation
mutation.mutate(variantData, {
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
resetModal()
queryClient.invalidateQueries({ queryKey: ['category', 'variants', id] })
}
const deleteVariant = async (variantType: 'sizes' | 'colors' | 'meterages', index: number) => {
if (!id || !categoryData) return
let updatedVariants
let variantData
switch (variantType) {
case 'sizes':
if (!categoryVariants.sizes || index >= categoryVariants.sizes.length) return
updatedVariants = categoryVariants.sizes.filter((_, i) => i !== index)
variantData = { id, sizes: updatedVariants }
break
case 'colors':
if (!categoryVariants.colors || index >= categoryVariants.colors.length) return
updatedVariants = categoryVariants.colors.filter((_, i) => i !== index)
variantData = { id, colors: updatedVariants }
break
case 'meterages':
if (!categoryVariants.meterages || index >= categoryVariants.meterages.length) return
updatedVariants = categoryVariants.meterages.filter((_, i) => i !== index)
variantData = { id, meterages: updatedVariants }
break
default:
return
}
await updateVariantMutation.mutateAsync({ ...variantData, theme: theme! })
queryClient.invalidateQueries({ queryKey: ['category', 'variants', id] })
}
return {
// Data
category,
theme,
categoryVariants,
isLoading,
// Modal state
isModalOpen,
newVariant,
// Actions
openModal,
closeModal,
setNewVariant,
addVariant,
deleteVariant,
// Loading states
isAddingLoading: createVariantMutation.isPending,
isUpdatingLoading: updateVariantMutation.isPending
}
}
+27 -2
View File
@@ -1,5 +1,5 @@
import axios from "../../../config/axios";
import { type CategoryType, type SingleUploadResponseType, type CategoriesResponseType, type CategoryByIdResponseType } from "../types/Types";
import { type CategoryType, type SingleUploadResponseType, type CategoriesResponseType, type CategoryByIdResponseType, type CategoryVariantsResponseType, type CategoryVariantsType } from "../types/Types";
export const createCategory = async (params: CategoryType) => {
const { data } = await axios.post(`/admin/category`, params);
@@ -22,7 +22,7 @@ export const updateCategory = async (id: string, params: Partial<CategoryType>)
};
export const deleteCategory = async (id: string) => {
const { data } = await axios.delete(`/admin/category/${id}`);
const { data } = await axios.delete(`/admin/category/${id}/delete`);
return data;
};
@@ -32,3 +32,28 @@ export const singleUpload = async (params: File): Promise<SingleUploadResponseTy
const { data } = await axios.post(`/admin/medias/upload/single`, formData);
return data;
};
export const getCategoryVariants = async (id: string): Promise<CategoryVariantsResponseType> => {
const { data } = await axios.get(`/category/${id}/variants`);
return data;
};
export const createCategoryVariant = async (params: CategoryVariantsType) => {
const { data } = await axios.post(`/admin/category/${params.id}/variants`, params);
return data;
};
export const updateCategoryVariant = async (params: CategoryVariantsType) => {
const { data } = await axios.patch(`/admin/category/${params.id}/variants`, params);
return data;
};
export const deleteCategoryVariant = async (params: { id: string; variantType: 'sizes' | 'colors' | 'meterages'; index: number }) => {
const { data } = await axios.delete(`/admin/category/${params.id}/variants`, {
data: {
variantType: params.variantType,
index: params.index
}
});
return data;
};
+61
View File
@@ -75,3 +75,64 @@ export type CategoryByIdResponseType = {
data: CategoryTreeNode;
};
};
export type ColorType = {
name: string;
hexColor: string;
};
export type SizeType = {
value: string;
};
export type MeterageType = {
value: string;
};
export type ProductVariantType = {
theme: string;
colors?: ColorType[];
sizes?: SizeType[];
meterages?: MeterageType[];
};
// تایپ‌های جدید برای variants دسته‌بندی
export type VariantType = {
_id: number;
value: string;
};
export type ColorVariantType = {
name: string;
hexColor: string;
};
export type CategoryVariantsType = {
id: string;
theme: string;
sizes?: VariantType[];
colors?: ColorVariantType[];
meterages?: VariantType[];
};
export type CategoryWithVariantsType = {
_id: string;
title_fa: string;
title_en: string;
theme: string;
icon: string;
imageUrl: string;
description: string;
leaf: boolean;
categoryVariants: CategoryVariantsType;
};
export type CategoryVariantsResponseType = {
status: number;
success: boolean;
results: {
data: {
category: CategoryWithVariantsType;
};
};
};
+2
View File
@@ -9,6 +9,7 @@ import Create from '../pages/category/Create'
import List from '../pages/category/List'
import Update from '../pages/category/Update'
import { Pages } from '../config/Pages'
import Variant from '../pages/category/Variant'
const MainRouter: FC = () => {
@@ -30,6 +31,7 @@ const MainRouter: FC = () => {
<Route path={Pages.category.create} element={<Create />} />
<Route path={Pages.category.list} element={<List />} />
<Route path={`${Pages.category.update}:id`} element={<Update />} />
<Route path={`${Pages.category.variant}:id`} element={<Variant />} />
</Routes>
</div>
</div>
+10 -6
View File
@@ -3,10 +3,13 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -14,14 +17,15 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
"include": [
"src"
]
}