blog category

This commit is contained in:
hamid zarghami
2025-10-28 11:50:14 +03:30
parent 2da582e913
commit e5185675be
8 changed files with 417 additions and 2 deletions
+202
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export { BlogFormFields } from "./BlogFormFields";
export { FileUploadSection } from "./FileUploadSection";
export { TagManager } from "./TagManager";
export { default as CategoryModal } from "./CategoryModal";
+25
View File
@@ -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,
});
};
+21
View File
@@ -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;
};
+8 -1
View File
@@ -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;
};
+4
View File
@@ -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>
+1 -1
View File
@@ -33,7 +33,7 @@ const BlogSubMenu: FC = () => {
/>
<SubMenuItem
title={'دسته بندی'}
isActive={isActive('create')}
isActive={isActive('category')}
link={Pages.blog.category}
/>
{/* <SubMenuItem