This commit is contained in:
hamid zarghami
2025-10-12 14:45:30 +03:30
parent 33aa1f2a8b
commit 37ed9bc074
9 changed files with 587 additions and 3 deletions
+2
View File
@@ -202,5 +202,7 @@ export const Pages = {
requestCreateProduct: "/sellers/request-create-product",
learningCategory: "/sellers/learning-category",
learning: "/sellers/learning",
learningCreate: "/sellers/learning/create",
learningUpdate: "/sellers/learning/update/",
},
};
+162
View File
@@ -0,0 +1,162 @@
import { type FC, useState } from 'react'
import { useFormik } from 'formik'
import { type CreateLearningType } from './types/Types'
import * as Yup from 'yup'
import { useCreateLearning, useGetLearningCategory } from './hooks/useSellerData'
import { useNavigate } from 'react-router-dom'
import Input from '../../components/Input'
import Select from '../../components/Select'
import Textarea from '../../components/Textarea'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { useUploadSingle } from '../uploader/hooks/useUploaderData'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { Pages } from '@/config/Pages'
const CreateLearning: FC = () => {
const navigate = useNavigate()
const { data: categoriesData } = useGetLearningCategory()
const uploadSingle = useUploadSingle()
const createLearningMutation = useCreateLearning()
const [videoFile, setVideoFile] = useState<File[]>([])
const [coverFile, setCoverFile] = useState<File[]>([])
const formik = useFormik<CreateLearningType>({
initialValues: {
title: '',
description: '',
videoUrl: '',
cover: '',
category: ''
},
validationSchema: Yup.object({
title: Yup.string().required('عنوان یادگیری الزامی است'),
description: Yup.string().required('توضیحات یادگیری الزامی است'),
category: Yup.string().required('دسته‌بندی یادگیری الزامی است')
}),
onSubmit: async (values) => {
let videoUrl = values.videoUrl
let coverUrl = values.cover
if (videoFile.length > 0) {
const videoResponse = await uploadSingle.mutateAsync(videoFile[0])
videoUrl = videoResponse.results?.url?.url || ''
}
if (coverFile.length > 0) {
const coverResponse = await uploadSingle.mutateAsync(coverFile[0])
coverUrl = coverResponse.results?.url?.url || ''
}
const submitData = {
...values,
videoUrl,
cover: coverUrl
}
createLearningMutation.mutate(submitData, {
onSuccess: () => {
toast.success('یادگیری با موفقیت ایجاد شد')
navigate(Pages.sellers.learning)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
},
})
const categories = categoriesData?.results?.learningCategory || []
const categoryOptions = categories.map(category => ({
value: category._id,
label: category.title
}))
console.log(formik.errors);
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ساخت یادگیری جدید</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={createLearningMutation.isPending || uploadSingle.isPending}
disabled={!formik.isValid || createLearningMutation.isPending || uploadSingle.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>ثبت یادگیری</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات اصلی</h2>
<div className='space-y-6'>
<Input
label="عنوان یادگیری"
placeholder="عنوان یادگیری را وارد کنید"
{...formik.getFieldProps('title')}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
/>
<Textarea
label="توضیحات یادگیری"
placeholder="توضیحات یادگیری را وارد کنید"
{...formik.getFieldProps('description')}
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
/>
<Select
label="دسته‌بندی"
placeholder="دسته‌بندی را انتخاب کنید"
items={categoryOptions}
value={formik.values.category}
onChange={(e) => formik.setFieldValue('category', e.target.value)}
error_text={formik.touched.category && formik.errors.category ? formik.errors.category : ''}
/>
</div>
</div>
{/* فایل‌ها */}
<div className='w-80 space-y-6'>
<div className='text-sm bg-white py-8 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>فایلها</h2>
<div className='space-y-6'>
<div>
<UploadBoxDraggble
label='ویدیو یادگیری'
isMultiple={false}
onChange={(files) => setVideoFile(files)}
/>
</div>
<div>
<UploadBoxDraggble
label='تصویر کاور'
isMultiple={false}
onChange={(files) => setCoverFile(files)}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default CreateLearning
+90
View File
@@ -0,0 +1,90 @@
import { type FC } from 'react'
import { useNavigate } from 'react-router-dom'
import { useGetLearning } from './hooks/useSellerData'
import type { Learning as LearningType } from './types/Types'
import PageLoading from '../../components/PageLoading'
import Error from '../../components/Error'
import Td from '../../components/Td'
import Button from '@/components/Button'
import LearningTableRow from './components/LearningTableRow'
import { Pages } from '@/config/Pages'
const Learning: FC = () => {
const navigate = useNavigate()
const { data: learningData, isLoading, error } = useGetLearning()
const handleEditLearning = (learning: LearningType) => {
navigate(`${Pages.sellers.learningUpdate}${learning._id}`)
}
const handleCreateLearning = () => {
navigate(Pages.sellers.learningCreate)
}
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 learnings = learningData?.results?.learning || []
return (
<div>
<div className='flex justify-end mt-5'>
<Button
label="ایجاد یادگیری جدید"
onClick={handleCreateLearning}
className="w-auto"
/>
</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={'دسته‌بندی'} />
<Td text={'وضعیت'} />
<Td text={'تاریخ ایجاد'} />
<Td text={'عملیات'} />
</tr>
</thead>
<tbody>
{learnings.length === 0 ? (
<tr className='tr'>
<td colSpan={7} className="text-center py-8 text-gray-500">
هیچ یادگیری یافت نشد
</td>
</tr>
) : (
learnings.map((learning: LearningType) => (
<LearningTableRow
key={learning._id}
learning={learning}
onEdit={handleEditLearning}
/>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default Learning
+188
View File
@@ -0,0 +1,188 @@
import { type FC, useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useFormik } from 'formik'
import { type CreateLearningType } from './types/Types'
import * as Yup from 'yup'
import { useUpdateLearning, useGetLearningById, useGetLearningCategory } from './hooks/useSellerData'
import Input from '../../components/Input'
import Select from '../../components/Select'
import Textarea from '../../components/Textarea'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { useUploadSingle } from '../uploader/hooks/useUploaderData'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { Pages } from '@/config/Pages'
import PageLoading from '@/components/PageLoading'
const UpdateLearning: FC = () => {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const { data: categoriesData } = useGetLearningCategory()
const { data: learningDetail, isLoading: learningDetailLoading } = useGetLearningById(id!)
const uploadSingle = useUploadSingle()
const updateLearningMutation = useUpdateLearning()
const [videoFile, setVideoFile] = useState<File[]>([])
const [coverFile, setCoverFile] = useState<File[]>([])
const formik = useFormik<CreateLearningType>({
initialValues: {
title: '',
description: '',
videoUrl: '',
cover: '',
category: ''
},
validationSchema: Yup.object({
title: Yup.string().required('عنوان یادگیری الزامی است'),
description: Yup.string().required('توضیحات یادگیری الزامی است'),
category: Yup.string().required('دسته‌بندی یادگیری الزامی است')
}),
onSubmit: async (values) => {
let videoUrl = values.videoUrl
let coverUrl = values.cover
if (videoFile.length > 0) {
const videoResponse = await uploadSingle.mutateAsync(videoFile[0])
videoUrl = videoResponse.results?.url?.url || ''
}
if (coverFile.length > 0) {
const coverResponse = await uploadSingle.mutateAsync(coverFile[0])
coverUrl = coverResponse.results?.url?.url || ''
}
const submitData = {
...values,
videoUrl,
cover: coverUrl
}
updateLearningMutation.mutate({
id: id!,
params: submitData
}, {
onSuccess: () => {
toast.success('یادگیری با موفقیت بروزرسانی شد')
navigate(Pages.sellers.learning)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
},
})
useEffect(() => {
if (learningDetail?.results?.learning) {
const learning = learningDetail.results.learning
formik.setValues({
title: learning.title,
description: learning.description,
videoUrl: learning.videoUrl,
cover: learning.cover,
category: learning.category._id
})
}
}, [learningDetail])
const categories = categoriesData?.results?.learningCategory || []
const categoryOptions = categories.map(category => ({
value: category._id,
label: category.title
}))
console.log(formik.errors);
if (learningDetailLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
)
}
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ویرایش یادگیری</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={updateLearningMutation.isPending || uploadSingle.isPending}
disabled={!formik.isValid || updateLearningMutation.isPending || uploadSingle.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>بروزرسانی یادگیری</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات اصلی</h2>
<div className='space-y-6'>
<Input
label="عنوان یادگیری"
placeholder="عنوان یادگیری را وارد کنید"
{...formik.getFieldProps('title')}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
/>
<Textarea
label="توضیحات یادگیری"
placeholder="توضیحات یادگیری را وارد کنید"
{...formik.getFieldProps('description')}
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
/>
<Select
label="دسته‌بندی"
placeholder="دسته‌بندی را انتخاب کنید"
items={categoryOptions}
value={formik.values.category}
onChange={(e) => formik.setFieldValue('category', e.target.value)}
error_text={formik.touched.category && formik.errors.category ? formik.errors.category : ''}
/>
</div>
</div>
{/* فایل‌ها */}
<div className='w-80 space-y-6'>
<div className='text-sm bg-white py-8 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>فایلها</h2>
<div className='space-y-6'>
<div>
<UploadBoxDraggble
label='ویدیو یادگیری'
isMultiple={false}
onChange={(files) => setVideoFile(files)}
/>
</div>
<div>
<UploadBoxDraggble
label='تصویر کاور'
isMultiple={false}
onChange={(files) => setCoverFile(files)}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default UpdateLearning
@@ -0,0 +1,88 @@
import { type FC } from 'react'
import Td from '../../../components/Td'
import { Calendar, Edit, Play } from 'iconsax-react'
import StatusWithText from '../../../components/StatusWithText'
import type { Learning as LearningType } from '../types/Types'
import { useDeleteLearning } from '../hooks/useSellerData'
import TrashWithConfrim from '@/components/TrashWithConfrim'
interface Props {
learning: LearningType
onEdit: (learning: LearningType) => void
}
const LearningTableRow: FC<Props> = ({ learning, onEdit }) => {
const { mutate: deleteLearningMutation, isPending } = useDeleteLearning()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<tr className='tr'>
<Td text="">
<div className="w-12 h-12 rounded-lg overflow-hidden">
<img
src={learning.cover || '/placeholder-image.png'}
alt={learning.title}
className="w-full h-full object-cover"
/>
</div>
</Td>
<Td text="">
<div className="max-w-xs">
<div className="font-medium text-gray-900 truncate">
{learning.title}
</div>
<div className="text-gray-500 text-xs truncate max-w-xs">
{learning.description}
</div>
</div>
</Td>
<Td text="">
<div className="flex items-center gap-1">
<Play size={16} color="#8C90A3" />
<span className="text-gray-600 text-sm">
ویدیو
</span>
</div>
</Td>
<Td text={learning.category.title} />
<Td text="">
<StatusWithText
variant={learning.deleted ? 'error' : 'success'}
text={learning.deleted ? 'غیرفعال' : 'فعال'}
/>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Calendar size={16} color="#8C90A3" />
<span className="text-gray-600 text-sm">
{formatDate(learning.createdAt)}
</span>
</div>
</Td>
<Td text="">
<div className="flex gap-2 items-center">
<Edit
onClick={() => onEdit(learning)}
color="#8C90A3"
size={18}
className="cursor-pointer hover:text-blue-500"
/>
<TrashWithConfrim
onDelete={() => deleteLearningMutation(learning._id)}
isLoading={isPending}
/>
</div>
</Td>
</tr>
)
}
export default LearningTableRow
+19 -1
View File
@@ -1,5 +1,5 @@
import * as api from "../service/SellerService";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateCategoryLearning,
CreateLearningType,
@@ -115,3 +115,21 @@ export const useUpdateLearning = () => {
api.updateLearning(id, params),
});
};
export const useGetLearningById = (id: string, enabled: boolean = true) => {
return useQuery({
queryKey: ["learning", id],
queryFn: () => api.getLearningById(id),
enabled: enabled && !!id,
});
};
export const useDeleteLearning = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteLearning,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["learning"] });
},
});
};
+12 -1
View File
@@ -9,6 +9,7 @@ import type {
GetRequestCreateProductResponse,
CreateCategoryLearning,
GetLearningCategoryResponse,
GetLearningResponse,
CreateLearningType,
} from "../types/Types";
@@ -116,7 +117,7 @@ export const updateLearningCategory = async (
return data;
};
export const getLearning = async () => {
export const getLearning = async (): Promise<GetLearningResponse> => {
const { data } = await axios.get(`/learning`);
return data;
};
@@ -133,3 +134,13 @@ export const updateLearning = async (
const { data } = await axios.patch(`/admin/learning/${id}/update`, params);
return data;
};
export const getLearningById = async (id: string) => {
const { data } = await axios.get(`/learning/${id}`);
return data;
};
export const deleteLearning = async (id: string) => {
const { data } = await axios.delete(`/admin/learning/${id}`);
return data;
};
+20
View File
@@ -335,6 +335,26 @@ export interface GetRequestCreateProductResponse {
};
}
export interface Learning {
_id: string;
title: string;
description: string;
videoUrl: string;
cover: string;
category: LearningCategory;
deleted: boolean;
createdAt: string;
updatedAt: string;
}
export interface GetLearningResponse {
status: number;
success: boolean;
results: {
learning: Learning[];
};
}
export interface CreateLearningType {
title: string;
description: string;
+6 -1
View File
@@ -68,6 +68,9 @@ import WithdrawalRequestPage from '@/pages/seller/WithdrawalRequest'
import Annoncement from '@/pages/seller/Annoncement'
import RequestCreateProduct from '@/pages/seller/RequestCreateProduct'
import CategoryLearning from '@/pages/seller/CategoryLearning'
import Learning from '@/pages/seller/Learning'
import CreateLearning from '@/pages/seller/CreateLearning'
import UpdateLearning from '@/pages/seller/UpdateLearning'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -169,7 +172,9 @@ const MainRouter: FC = () => {
<Route path={Pages.sellers.announcement} element={<Annoncement />} />
<Route path={Pages.sellers.requestCreateProduct} element={<RequestCreateProduct />} />
<Route path={Pages.sellers.learningCategory} element={<CategoryLearning />} />
{/* <Route path={Pages.sellers.learning} element={<Learning />} /> */}
<Route path={Pages.sellers.learning} element={<Learning />} />
<Route path={Pages.sellers.learningCreate} element={<CreateLearning />} />
<Route path={`${Pages.sellers.learningUpdate}:id`} element={<UpdateLearning />} />
</Routes>
</div>
</div>