diff --git a/.env b/.env index 72d7673..459db48 100644 --- a/.env +++ b/.env @@ -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' \ No newline at end of file diff --git a/src/components/DefaulModal.tsx b/src/components/DefaulModal.tsx index 7d64bb6..cad832a 100644 --- a/src/components/DefaulModal.tsx +++ b/src/components/DefaulModal.tsx @@ -26,9 +26,9 @@ const DefaulModal: FC = (props: Props) => { { props.open && ( -
+
-
+
{ @@ -46,7 +46,7 @@ const DefaulModal: FC = (props: Props) => {
-
+
) } diff --git a/src/components/Select.tsx b/src/components/Select.tsx index fbce0f1..046e424 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -31,7 +31,7 @@ const Select: FC = (props: Props) => { )}> { props.placeholder && - + } { props.items?.map((item) => { diff --git a/src/config/Pages.ts b/src/config/Pages.ts index 3eaee89..05f52fe 100644 --- a/src/config/Pages.ts +++ b/src/config/Pages.ts @@ -121,5 +121,6 @@ export const Pages = { create: "/category/create", update: "/category/update/", detail: "/category/", + variant: "/category/variant/", }, }; diff --git a/src/helpers/types.ts b/src/helpers/types.ts index d8f7039..c800121 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -18,3 +18,12 @@ export type ErrorType = Error & { }; }; }; + +export interface IGeneralError { + status: number; + success: boolean; + error: { + message: string; + details: string[]; + }; +} diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index aa728fe..26a2e0a 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -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; + } +}; diff --git a/src/pages/category/Create.tsx b/src/pages/category/Create.tsx index 01c4860..d909080 100644 --- a/src/pages/category/Create.tsx +++ b/src/pages/category/Create.tsx @@ -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)} diff --git a/src/pages/category/List.tsx b/src/pages/category/List.tsx index a53501c..54d75b9 100644 --- a/src/pages/category/List.tsx +++ b/src/pages/category/List.tsx @@ -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>(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 = () => {
- + + {category.leaf ? + +
+
+ {colors.map((color: ColorVariantType, index: number) => ( +
+
+
+ {color.name} +
+ onDelete(index)} + isLoading={isLoading} + /> +
+ ))} +
+
+ ) +} + +export default VariantColorsList diff --git a/src/pages/category/components/VariantEmptyState.tsx b/src/pages/category/components/VariantEmptyState.tsx new file mode 100644 index 0000000..7792825 --- /dev/null +++ b/src/pages/category/components/VariantEmptyState.tsx @@ -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 = ({ type, onAddClick }) => { + const getContent = () => { + switch (type) { + case 'sizes': + return { + icon: ( + + + + ), + title: 'هیچ سایزی تعریف نشده', + description: 'برای شروع، سایز جدیدی اضافه کنید', + buttonText: 'اضافه کردن اولین سایز' + } + case 'colors': + return { + icon: ( + + + + ), + title: 'هیچ رنگی تعریف نشده', + description: 'برای شروع، رنگ جدیدی اضافه کنید', + buttonText: 'اضافه کردن اولین رنگ' + } + case 'meterages': + return { + icon: ( + + + + ), + title: 'هیچ متریاژی تعریف نشده', + description: 'برای شروع، متریاژ جدیدی اضافه کنید', + buttonText: 'اضافه کردن اولین متریاژ' + } + } + } + + const { icon, title, description, buttonText } = getContent() + + return ( +
+
+
+ {icon} +
+
+

{title}

+

{description}

+
+ +
+
+ ) +} + +export default VariantEmptyState diff --git a/src/pages/category/components/VariantMeteragesList.tsx b/src/pages/category/components/VariantMeteragesList.tsx new file mode 100644 index 0000000..97703a4 --- /dev/null +++ b/src/pages/category/components/VariantMeteragesList.tsx @@ -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 = ({ + meterages, + onAddClick, + onDelete, + isLoading +}) => { + if (!meterages || meterages.length === 0) { + return + } + + return ( +
+
+

متریاژ:

+ +
+
+ {meterages.map((meterage: VariantType, index: number) => ( +
+ {meterage.value} + onDelete(index)} + isLoading={isLoading} + /> +
+ ))} +
+
+ ) +} + +export default VariantMeteragesList diff --git a/src/pages/category/components/VariantModal.tsx b/src/pages/category/components/VariantModal.tsx new file mode 100644 index 0000000..3899df2 --- /dev/null +++ b/src/pages/category/components/VariantModal.tsx @@ -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 = ({ + 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 ( + ) => onVariantChange({ ...newVariant, value: e.target.value })} + /> + ) + + case CategoryThemeEnum.Colored: + return ( +
+ ) => onVariantChange({ ...newVariant, name: e.target.value })} + /> +
+ +
+ ) => onVariantChange({ ...newVariant, hexColor: e.target.value })} + className="w-12 h-10 border border-gray-300 rounded cursor-pointer" + /> + ) => onVariantChange({ ...newVariant, hexColor: e.target.value })} + className="flex-1" + /> +
+
+
+ ) + + case CategoryThemeEnum.Meterage: + return ( + ) => 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 ( + +
+ {renderFormContent()} + +
+ + +
+
+
+ ) +} + +export default VariantModal diff --git a/src/pages/category/components/VariantSizesList.tsx b/src/pages/category/components/VariantSizesList.tsx new file mode 100644 index 0000000..a514391 --- /dev/null +++ b/src/pages/category/components/VariantSizesList.tsx @@ -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 = ({ + sizes, + onAddClick, + onDelete, + isLoading +}) => { + if (!sizes || sizes.length === 0) { + return + } + + return ( +
+
+

سایزها:

+ +
+
+ {sizes.map((size: VariantType, index: number) => ( +
+ {size.value} + onDelete(index)} + isLoading={isLoading} + /> +
+ ))} +
+
+ ) +} + +export default VariantSizesList diff --git a/src/pages/category/components/index.ts b/src/pages/category/components/index.ts index 5d9b12d..83567f5 100644 --- a/src/pages/category/components/index.ts +++ b/src/pages/category/components/index.ts @@ -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"; diff --git a/src/pages/category/enum/Enum.ts b/src/pages/category/enum/Enum.ts new file mode 100644 index 0000000..67de7ad --- /dev/null +++ b/src/pages/category/enum/Enum.ts @@ -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", +} diff --git a/src/pages/category/hooks/useCategoryData.tsx b/src/pages/category/hooks/useCategoryData.tsx index 78f55f7..30bdbc8 100644 --- a/src/pages/category/hooks/useCategoryData.tsx +++ b/src/pages/category/hooks/useCategoryData.tsx @@ -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, + }); +}; \ No newline at end of file diff --git a/src/pages/category/hooks/useVariantLogic.tsx b/src/pages/category/hooks/useVariantLogic.tsx new file mode 100644 index 0000000..b8aa058 --- /dev/null +++ b/src/pages/category/hooks/useVariantLogic.tsx @@ -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 + } +} diff --git a/src/pages/category/service/CategoryService.ts b/src/pages/category/service/CategoryService.ts index 5331d71..5841899 100644 --- a/src/pages/category/service/CategoryService.ts +++ b/src/pages/category/service/CategoryService.ts @@ -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) }; 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 => { + 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; +}; \ No newline at end of file diff --git a/src/pages/category/types/Types.ts b/src/pages/category/types/Types.ts index 2f779f1..1b65518 100644 --- a/src/pages/category/types/Types.ts +++ b/src/pages/category/types/Types.ts @@ -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; + }; + }; +}; diff --git a/src/router/Main.tsx b/src/router/Main.tsx index da2e43a..4923248 100644 --- a/src/router/Main.tsx +++ b/src/router/Main.tsx @@ -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 = () => { } /> } /> } /> + } /> diff --git a/tsconfig.app.json b/tsconfig.app.json index 227a6c6..f89b191 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -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" + ] +} \ No newline at end of file