blog category
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { type FC, useState, type ReactElement } from 'react'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import { useGetBlogCategories, useDeleteBlogCategory } from './hooks/useBlogsData'
|
||||
import { type BlogCategory } from './types/Types'
|
||||
import PageLoading from '@/components/PageLoading'
|
||||
import Error from '@/components/Error'
|
||||
import Td from '@/components/Td'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import { Edit, ArrowDown2, ArrowLeft2 } from 'iconsax-react'
|
||||
import Button from '@/components/Button'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/helpers/utils'
|
||||
import { CategoryModal } from './components'
|
||||
|
||||
const BlogCategoryList: FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false)
|
||||
const [selectedCategory, setSelectedCategory] = useState<BlogCategory | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: categoriesData, isLoading, error } = useGetBlogCategories()
|
||||
const deleteCategoryMutation = useDeleteBlogCategory()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<PageLoading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<Error errorText="خطا در بارگذاری دستهبندیها" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteCategory = (categoryId: string) => {
|
||||
deleteCategoryMutation.mutate(categoryId, {
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی با موفقیت حذف شد')
|
||||
queryClient.invalidateQueries({ queryKey: ['blog-categories'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditCategory = (category: BlogCategory) => {
|
||||
setSelectedCategory(category)
|
||||
setIsUpdateModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseUpdateModal = () => {
|
||||
setIsUpdateModalOpen(false)
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
const toggleExpand = (categoryId: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(categoryId)) {
|
||||
newSet.delete(categoryId)
|
||||
} else {
|
||||
newSet.add(categoryId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const categories = categoriesData?.results?.categories || []
|
||||
|
||||
// تابع بازگشتی برای رندر کردن دستهبندیها به صورت درختی
|
||||
const renderCategory = (category: BlogCategory, level: number = 0): ReactElement[] => {
|
||||
const hasChildren = category.children && category.children.length > 0
|
||||
const isExpanded = expandedCategories.has(category._id)
|
||||
|
||||
const categoryRow = (
|
||||
<tr key={category._id} className='tr'>
|
||||
<Td text="">
|
||||
<div className="flex items-center gap-2" style={{ paddingRight: `${level * 24}px` }}>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleExpand(category._id)}
|
||||
className="hover:bg-gray-100 rounded p-1"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ArrowDown2 size={16} color='#8C90A3' />
|
||||
) : (
|
||||
<ArrowLeft2 size={16} color='#8C90A3' />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-6"></div>}
|
||||
<span>{category.title_fa}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td text={category.slug} />
|
||||
<Td text="">
|
||||
<div className="max-w-xs truncate">
|
||||
{category.description || '-'}
|
||||
</div>
|
||||
</Td>
|
||||
<Td text="">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleEditCategory(category)}>
|
||||
<Edit
|
||||
color='#8C90A3'
|
||||
size={16}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<TrashWithConfrim
|
||||
onDelete={() => handleDeleteCategory(category._id)}
|
||||
isLoading={deleteCategoryMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</Td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
const rows = [categoryRow]
|
||||
|
||||
// اگر دستهبندی فرزند دارد و باز شده است، آنها را هم نمایش بده
|
||||
if (hasChildren && isExpanded) {
|
||||
category.children.forEach(child => {
|
||||
rows.push(...renderCategory(child, level + 1))
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// رندر همه دستهبندیهای اصلی
|
||||
const renderAllCategories = () => {
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<tr className='tr'>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-500">
|
||||
هیچ دستهبندی یافت نشد
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
const allRows: ReactElement[] = []
|
||||
categories.forEach(category => {
|
||||
allRows.push(...renderCategory(category))
|
||||
})
|
||||
return allRows
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageTitle />
|
||||
<div className='flex justify-end mt-5'>
|
||||
<Button
|
||||
label='افزودن دستهبندی'
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className='w-fit'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative overflow-x-auto rounded-3xl mt-5 w-full'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='thead'>
|
||||
<tr>
|
||||
<Td text={'عنوان'} />
|
||||
<Td text={'اسلاگ'} />
|
||||
<Td text={'توضیحات'} />
|
||||
<Td text={'عملیات'} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderAllCategories()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CategoryModal
|
||||
open={isCreateModalOpen}
|
||||
close={() => setIsCreateModalOpen(false)}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
<CategoryModal
|
||||
open={isUpdateModalOpen}
|
||||
close={handleCloseUpdateModal}
|
||||
mode="update"
|
||||
category={selectedCategory}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogCategoryList
|
||||
@@ -0,0 +1,155 @@
|
||||
import { type FC, useEffect } from 'react'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
import Input from '@/components/Input'
|
||||
import Textarea from '@/components/Textarea'
|
||||
import Select from '@/components/Select'
|
||||
import Button from '@/components/Button'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { useCreateBlogCategory, useUpdateBlogCategory, useGetBlogCategories } from '../hooks/useBlogsData'
|
||||
import { type CreateCategoryBlogType, type BlogCategory } from '../types/Types'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/helpers/utils'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
interface CategoryModalProps {
|
||||
open: boolean
|
||||
close: () => void
|
||||
mode: 'create' | 'update'
|
||||
category?: BlogCategory | null
|
||||
}
|
||||
|
||||
const CategoryModal: FC<CategoryModalProps> = ({ open, close, mode, category }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: categoriesData } = useGetBlogCategories()
|
||||
const createCategoryMutation = useCreateBlogCategory()
|
||||
const updateCategoryMutation = useUpdateBlogCategory()
|
||||
|
||||
const formik = useFormik<CreateCategoryBlogType>({
|
||||
initialValues: {
|
||||
title_fa: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
parent: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
title_fa: Yup.string().required('عنوان دستهبندی الزامی است'),
|
||||
slug: Yup.string().required('اسلاگ الزامی است'),
|
||||
description: Yup.string().required('توضیحات الزامی است'),
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const submitData = {
|
||||
...values,
|
||||
parent: values.parent || undefined,
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
createCategoryMutation.mutate(submitData, {
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی با موفقیت ایجاد شد')
|
||||
queryClient.invalidateQueries({ queryKey: ['blog-categories'] })
|
||||
formik.resetForm()
|
||||
close()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
}
|
||||
})
|
||||
} else if (mode === 'update' && category) {
|
||||
updateCategoryMutation.mutate(
|
||||
{ id: category._id, params: submitData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی با موفقیت ویرایش شد')
|
||||
queryClient.invalidateQueries({ queryKey: ['blog-categories'] })
|
||||
formik.resetForm()
|
||||
close()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'update' && category) {
|
||||
formik.setValues({
|
||||
title_fa: category.title_fa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
parent: category.parent || '',
|
||||
})
|
||||
} else if (mode === 'create') {
|
||||
formik.resetForm()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, category, open])
|
||||
|
||||
const parentOptions = categoriesData?.results?.categories
|
||||
?.filter((c: BlogCategory) => c._id !== category?._id)
|
||||
?.map((c: BlogCategory) => ({
|
||||
value: c._id,
|
||||
label: c.title_fa
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<DefaulModal
|
||||
open={open}
|
||||
close={close}
|
||||
isHeader={true}
|
||||
title_header={mode === 'create' ? 'افزودن دستهبندی جدید' : 'ویرایش دستهبندی'}
|
||||
width={600}
|
||||
>
|
||||
<div className='mt-6 space-y-4 w-[450px]'>
|
||||
<Input
|
||||
label='عنوان دستهبندی'
|
||||
placeholder='مثال: تکنولوژی'
|
||||
{...formik.getFieldProps('title_fa')}
|
||||
error_text={formik.touched.title_fa && formik.errors.title_fa ? formik.errors.title_fa : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label='اسلاگ'
|
||||
placeholder='مثال: technology'
|
||||
{...formik.getFieldProps('slug')}
|
||||
error_text={formik.touched.slug && formik.errors.slug ? formik.errors.slug : ''}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label='توضیحات'
|
||||
placeholder='توضیحات دستهبندی را وارد کنید...'
|
||||
{...formik.getFieldProps('description')}
|
||||
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='دسته والد (اختیاری)'
|
||||
placeholder='انتخاب دسته والد'
|
||||
items={parentOptions}
|
||||
{...formik.getFieldProps('parent')}
|
||||
error_text={formik.touched.parent && formik.errors.parent ? formik.errors.parent : ''}
|
||||
/>
|
||||
|
||||
<div className='flex gap-3 justify-end mt-6'>
|
||||
<Button
|
||||
label='لغو'
|
||||
onClick={close}
|
||||
className='bg-gray-200 text-gray-700'
|
||||
/>
|
||||
<Button
|
||||
label={mode === 'create' ? 'ایجاد' : 'بروزرسانی'}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isLoading={createCategoryMutation.isPending || updateCategoryMutation.isPending}
|
||||
disabled={!formik.isValid || createCategoryMutation.isPending || updateCategoryMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryModal
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BlogFormFields } from "./BlogFormFields";
|
||||
export { FileUploadSection } from "./FileUploadSection";
|
||||
export { TagManager } from "./TagManager";
|
||||
export { default as CategoryModal } from "./CategoryModal";
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CreateBlogType,
|
||||
UpdateBlogType,
|
||||
BlogCategoriesResponse,
|
||||
CreateCategoryBlogType,
|
||||
} from "../types/Types";
|
||||
import { useSharedStore } from "@/shared/store/sharedStore";
|
||||
export const useGetBlogs = (page: number = 1) => {
|
||||
@@ -48,3 +49,27 @@ export const useDeleteBlog = () => {
|
||||
mutationFn: (id: string) => api.deleteBlog(id),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateBlogCategory = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.createBlogCategory,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateBlogCategory = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
params,
|
||||
}: {
|
||||
id: string;
|
||||
params: CreateCategoryBlogType;
|
||||
}) => api.updateBlogCategory(id, params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteBlogCategory = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.deleteBlogCategory,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
UpdateBlogType,
|
||||
BlogCategoriesResponse,
|
||||
BlogDetailResponse,
|
||||
CreateCategoryBlogType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getBlogs = async (
|
||||
@@ -44,3 +45,23 @@ export const deleteBlog = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/blog/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
// category
|
||||
|
||||
export const createBlogCategory = async (params: CreateCategoryBlogType) => {
|
||||
const { data } = await axios.post(`/admin/blog/category`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateBlogCategory = async (
|
||||
id: string,
|
||||
params: CreateCategoryBlogType
|
||||
) => {
|
||||
const { data } = await axios.post(`/admin/blog/category/${id}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteBlogCategory = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/blog/category/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface BlogCategory {
|
||||
description: string;
|
||||
parent: string | null;
|
||||
leaf: boolean;
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
updatedAt: string;
|
||||
children: BlogCategory[];
|
||||
}
|
||||
@@ -99,3 +99,10 @@ export interface BlogDetailResponse {
|
||||
blog: Blog;
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateCategoryBlogType = {
|
||||
title_fa: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import OrderDetail from '@/pages/orders/Detail'
|
||||
import BlogsList from '@/pages/blogs/List'
|
||||
import CreateBlog from '@/pages/blogs/Create'
|
||||
import UpdateBlog from '@/pages/blogs/Update'
|
||||
import BlogCategory from '@/pages/blogs/Category'
|
||||
import ShippingList from '@/pages/shipment/List'
|
||||
import CreateShipment from '@/pages/shipment/Create'
|
||||
import CouponList from '@/pages/Coupon/List'
|
||||
@@ -124,6 +125,7 @@ const MainRouter: FC = () => {
|
||||
<Route path={Pages.blog.list} element={<BlogsList />} />
|
||||
<Route path={Pages.blog.create} element={<CreateBlog />} />
|
||||
<Route path={`${Pages.blog.detail}:id`} element={<UpdateBlog />} />
|
||||
<Route path={Pages.blog.category} element={<BlogCategory />} />
|
||||
|
||||
<Route path={Pages.shipment.list} element={<ShippingList />} />
|
||||
<Route path={Pages.shipment.create} element={<CreateShipment />} />
|
||||
@@ -192,6 +194,8 @@ const MainRouter: FC = () => {
|
||||
|
||||
<Route path={Pages.products.reviews.list} element={<Reviews />} />
|
||||
<Route path={Pages.products.questions.list} element={<Questions />} />
|
||||
|
||||
<Route path={Pages.blog.category} element={<BlogCategory />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ const BlogSubMenu: FC = () => {
|
||||
/>
|
||||
<SubMenuItem
|
||||
title={'دسته بندی'}
|
||||
isActive={isActive('create')}
|
||||
isActive={isActive('category')}
|
||||
link={Pages.blog.category}
|
||||
/>
|
||||
{/* <SubMenuItem
|
||||
|
||||
Reference in New Issue
Block a user