diff --git a/src/config/Pages.ts b/src/config/Pages.ts index bc9dc43..3693b93 100644 --- a/src/config/Pages.ts +++ b/src/config/Pages.ts @@ -72,6 +72,7 @@ export const Pages = { setting: { shop: "/settings/shop", banners: "/settings/banners", + sliders: "/settings/sliders", siteSetting: "/settings/site-setting", }, wallet: "/wallet", diff --git a/src/pages/products/components/Step2Form.tsx b/src/pages/products/components/Step2Form.tsx index a916e5f..6794448 100644 --- a/src/pages/products/components/Step2Form.tsx +++ b/src/pages/products/components/Step2Form.tsx @@ -13,6 +13,12 @@ interface AttributeSelectorProps { onChange: (attributeId: number, values: number[]) => void; } +interface TextNumberInputProps { + attribute: CategoryAttributeType; + value: string; + onChange: (attributeId: number, value: string) => void; +} + const AttributeSelector: FC = ({ attribute, selectedValues, onChange }) => { const handleValueChange = (valueId: number, checked: boolean) => { if (attribute.multiple) { @@ -61,6 +67,55 @@ const AttributeSelector: FC = ({ attribute, selectedValu ); }; +const TextNumberInput: FC = ({ attribute, value, onChange }) => { + if (attribute.type === 'boolean') { + return ( +
+ + + {attribute.hint && ( +

{attribute.hint}

+ )} + +
+ onChange(attribute._id, e.target.checked ? 'true' : 'false')} + className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2" + /> + بله +
+
+ ); + } + + return ( +
+ + + {attribute.hint && ( +

{attribute.hint}

+ )} + + onChange(attribute._id, e.target.value)} + placeholder={`مقدار ${attribute.title} را وارد کنید`} + className="w-full" + required={attribute.required} + /> +
+ ); +}; + interface Step2FormProps { onSubmit: (data: Omit) => void; loading: boolean; @@ -69,6 +124,11 @@ interface Step2FormProps { initialData?: Partial>; } +interface AttributeWithValue { + id: number; + values: number[] | string[]; +} + // کامپوننت مدیریت تگ‌ها interface TagInputProps { tags: string[]; @@ -200,7 +260,7 @@ const ListInput: FC = ({ items, onChange, placeholder, label }) const Step2Form: FC = ({ onSubmit, loading, onPrevious, categoryId, initialData }) => { const [formData, setFormData] = useState>(() => ({ - attributes: initialData?.attributes || [], + attributes: (initialData?.attributes || []) as AttributeWithValue[], description: initialData?.description || '', metaDescription: initialData?.metaDescription || '', tags: initialData?.tags || [], @@ -220,6 +280,16 @@ const Step2Form: FC = ({ onSubmit, loading, onPrevious, category })); }; + const handleTextNumberAttributeChange = (attributeId: number, value: string) => { + setFormData(prev => ({ + ...prev, + attributes: prev.attributes.filter(attr => attr.id !== attributeId).concat({ + id: attributeId, + values: value ? [value] : [] + }) + })); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit(formData); @@ -234,12 +304,26 @@ const Step2Form: FC = ({ onSubmit, loading, onPrevious, category {categoryAttributesData.results.data.category.attributes.map((attribute) => { const selectedAttribute = formData.attributes.find(attr => attr.id === attribute._id); const selectedValues = selectedAttribute?.values || []; + const attributeValue = selectedValues.length > 0 ? String(selectedValues[0]) : ''; + // برای attributeهای text، number و boolean از TextNumberInput استفاده کن + if (attribute.type === 'text' || attribute.type === 'number' || attribute.type === 'boolean') { + return ( + + ); + } + + // برای attributeهای انتخابی از AttributeSelector استفاده کن return ( ); diff --git a/src/pages/products/types/Types.ts b/src/pages/products/types/Types.ts index ce834bb..f4e0faf 100644 --- a/src/pages/products/types/Types.ts +++ b/src/pages/products/types/Types.ts @@ -33,7 +33,7 @@ export type CreateProductDetailResponseType = { // مرحله دوم: ویژگی‌ها و توضیحات export type ProductAttributeType = { id: number; - values: number[]; + values: number[] | string[]; }; export type CreateProductAttributeRequestType = { diff --git a/src/pages/setting/SiteSetting.tsx b/src/pages/setting/SiteSetting.tsx index 9935a1d..b43e53d 100644 --- a/src/pages/setting/SiteSetting.tsx +++ b/src/pages/setting/SiteSetting.tsx @@ -42,7 +42,6 @@ const validationSchema = Yup.object().shape({ footerAddress: Yup.string().trim().required('آدرس فوتر الزامی است'), footerDescription: Yup.string().trim().required('توضیحات فوتر الزامی است'), shopPostalCode: Yup.string().trim(), - siteLogo: Yup.string(), downloadAppDetails: Yup.array(), socialMediaLinks: Yup.array() }) @@ -50,13 +49,15 @@ const validationSchema = Yup.object().shape({ const SiteSetting: FC = () => { const { data, isLoading } = useGetSiteSetting() - const { data: shopData } = useGetShop() + const { data: shopData, isLoading: isShopLoading } = useGetShop() const updateSiteSetting = useUpdateSiteSetting() const [isDataLoaded, setIsDataLoaded] = useState(false) const [uploadingIndexes, setUploadingIndexes] = useState>(new Set()) const [pendingFiles, setPendingFiles] = useState<{ [key: string]: File }>({}) const singleUpload = useSingleUpload() + + const formik = useFormik({ initialValues: { siteLogo: undefined, @@ -119,21 +120,26 @@ const SiteSetting: FC = () => { } }) + useEffect(() => { - if (data?.results?.siteSetting && shopData?.results?.shop) { + // وقتی loading تمام شد، داده‌ها را مقداردهی اولیه کن + if (!isLoading && !isShopLoading) { + const siteSetting = data?.results?.siteSetting || {} + const shop = shopData?.results?.shop || {} + formik.setValues({ - siteLogo: data.results.siteSetting.siteLogo || undefined, - footerPhone: data.results.siteSetting.footerPhone || '', - footerEmail: data.results.siteSetting.footerEmail || '', - footerAddress: data.results.siteSetting.footerAddress || '', - footerDescription: data.results.siteSetting.footerDescription || '', - shopPostalCode: shopData.results.shop.shopPostalCode || '', - downloadAppDetails: (data.results.siteSetting.downloadAppDetails || []).map((item: { _id?: string; pic: string; url: string }, index: number) => ({ + siteLogo: siteSetting.siteLogo || undefined, + footerPhone: siteSetting.footerPhone || '', + footerEmail: siteSetting.footerEmail || '', + footerAddress: siteSetting.footerAddress || '', + footerDescription: siteSetting.footerDescription || '', + shopPostalCode: (shop && 'shopPostalCode' in shop ? String(shop.shopPostalCode) : '') || '', + downloadAppDetails: (siteSetting.downloadAppDetails || []).map((item: { _id?: string; pic: string; url: string }, index: number) => ({ id: item._id || `existing-${index}-${Date.now()}`, pic: item.pic, url: item.url })), - socialMediaLinks: (data.results.siteSetting.socialMediaLinks || []).map((item: { icon: string; url: string }, index: number) => ({ + socialMediaLinks: (siteSetting.socialMediaLinks || []).map((item: { icon: string; url: string }, index: number) => ({ id: `existing-${index}-${Date.now()}`, icon: item.icon, url: item.url @@ -142,7 +148,7 @@ const SiteSetting: FC = () => { setIsDataLoaded(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, shopData]) + }, [data, shopData, isLoading, isShopLoading]) const uploadAllPendingFiles = async (): Promise<{ [key: string]: string }> => { @@ -201,7 +207,9 @@ const SiteSetting: FC = () => { } - if (isLoading) { + + + if (isLoading || isShopLoading) { return
در حال بارگذاری...
} diff --git a/src/pages/setting/Sliders.tsx b/src/pages/setting/Sliders.tsx new file mode 100644 index 0000000..1e06eda --- /dev/null +++ b/src/pages/setting/Sliders.tsx @@ -0,0 +1,214 @@ +import { useState, type FC } from 'react' +import PageTitle from '@/components/PageTitle' +import { useDeleteSlider, useGetSliders, useUpdateSlider } from './hooks/useSettingData' +import { type SliderItem, type UpdateSliderType } from './types/Types' +import PageLoading from '@/components/PageLoading' +import Error from '@/components/Error' +import Td from '@/components/Td' +import { Calendar, Add, Edit, Eye } from 'iconsax-react' +import Button from '@/components/Button' +import TrashWithConfrim from '@/components/TrashWithConfrim' +import Switch from '@/components/Switch' +import CreateSliderModal from './components/CreateSliderModal' +import EditSliderModal from './components/EditSliderModal' + +const Sliders: FC = () => { + const { data, isLoading, error, refetch } = useGetSliders() + const updateSlider = useUpdateSlider() + const deleteSlider = useDeleteSlider() + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [editingSlider, setEditingSlider] = useState(null) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ +
+ ) + } + + const sliders = data?.results?.sliders || [] + + const formatDate = (dateString: string) => { + if (!dateString) return '-' + return new Date(dateString).toLocaleDateString('fa-IR') + } + + const truncateText = (text: string, maxLength: number = 30) => { + if (text.length <= maxLength) return text + return text.substring(0, maxLength) + '...' + } + + const getDisplayLocationLabel = (location: 'desktop' | 'mobile') => { + return location === 'desktop' ? 'دسکتاپ' : 'موبایل' + } + + const handleStatusToggle = (slider: SliderItem) => { + const updateData: UpdateSliderType = { + displayLocation: slider.displayLocation, + imageUrl: slider.imageUrl, + altText: slider.altText, + title: slider.title, + linkUrl: slider.linkUrl, + isActive: !slider.isActive, + } + + updateSlider.mutate({ id: slider._id, params: updateData }) + } + + return ( +
+ +
+
+

مدیریت اسلایدرها

+
+ +
+ +
+ + + + + + + {sliders.length === 0 ? ( + + + + ) : ( + sliders.map((slider: SliderItem) => ( + + + + + + + + + + + )) + )} + +
+ + + + + + + +
+ هیچ اسلایدری یافت نشد +
+
+ {slider.altText} +
+
+
+ {slider.title} +
+
+
+ + {truncateText(slider.altText)} + +
+
+ + + + {getDisplayLocationLabel(slider.displayLocation)} + + + handleStatusToggle(slider)} + isLoading={updateSlider.isPending} + /> + +
+ + {formatDate(slider.createdAt)} +
+
+
+ + { + deleteSlider.mutate(slider._id, { + onSuccess: () => { + refetch() + } + }) + }} + isLoading={deleteSlider.isPending} + /> +
+
+
+ + setIsCreateModalOpen(false)} + onSuccess={() => { + setIsCreateModalOpen(false) + refetch() + }} + /> + + {editingSlider && ( + setEditingSlider(null)} + onSuccess={() => { + setEditingSlider(null) + refetch() + }} + /> + )} +
+ ) +} + +export default Sliders + diff --git a/src/pages/setting/components/CreateSliderModal.tsx b/src/pages/setting/components/CreateSliderModal.tsx new file mode 100644 index 0000000..0132a07 --- /dev/null +++ b/src/pages/setting/components/CreateSliderModal.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react' +import { type FC } from 'react' +import Button from '@/components/Button' +import Input from '@/components/Input' +import DefaulModal from '@/components/DefaulModal' +import Select from '@/components/Select' +import { useCreateSlider } from '../hooks/useSettingData' +import { useUploadSingle } from '@/pages/uploader/hooks/useUploaderData' +import { type CreateSliderType } from '../types/Types' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/helpers/utils' +import UploadBox from '@/components/UploadBox' +import Switch from '@/components/Switch' + +interface CreateSliderModalProps { + isOpen: boolean + onClose: () => void + onSuccess: () => void +} + +const CreateSliderModal: FC = ({ isOpen, onClose }) => { + const [formData, setFormData] = useState({ + displayLocation: 'desktop', + imageUrl: '', + altText: '', + title: '', + linkUrl: '', + isActive: true, + }) + + const [imageFile, setImageFile] = useState([]) + + const createSliderMutation = useCreateSlider() + const uploadSingle = useUploadSingle() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.title || !formData.altText || !formData.linkUrl) { + toast.error('لطفا تمام فیلدهای الزامی را پر کنید') + return + } + + if (imageFile.length === 0) { + toast.error('لطفا تصویر اسلایدر را انتخاب کنید') + return + } + + try { + // Upload image first + const uploadResponse = await uploadSingle.mutateAsync(imageFile[0]) + const imageUrl = uploadResponse.results?.url?.url + + if (!imageUrl) { + toast.error('خطا در آپلود تصویر') + return + } + + // Create slider with uploaded image URL + await createSliderMutation.mutateAsync({ + displayLocation: formData.displayLocation, + imageUrl, + altText: formData.altText, + title: formData.title, + linkUrl: formData.linkUrl, + isActive: formData.isActive, + }, { + onError: (error) => { + toast.error(extractErrorMessage(error)) + } + }) + + toast.success('اسلایدر با موفقیت اضافه شد') + handleClose() + } catch (error) { + toast.error('خطا در آپلود تصویر') + } + } + + const handleClose = () => { + setFormData({ + displayLocation: 'desktop', + imageUrl: '', + altText: '', + title: '', + linkUrl: '', + isActive: true, + }) + setImageFile([]) + onClose() + } + + const displayLocationOptions = [ + { value: 'desktop', label: 'دسکتاپ' }, + { value: 'mobile', label: 'موبایل' }, + ] + + return ( + +
+ {/* محل نمایش */} +
+ setFormData(prev => ({ ...prev, title: e.target.value }))} + placeholder="عنوان اسلایدر را وارد کنید" + required + /> +
+ + {/* متن جایگزین */} +
+ setFormData(prev => ({ ...prev, altText: e.target.value }))} + placeholder="توضیح تصویر برای دسترسی" + required + /> +
+ + {/* لینک */} +
+ setFormData(prev => ({ ...prev, linkUrl: e.target.value }))} + placeholder="/summer-sale" + required + /> +
+ + {/* وضعیت */} +
+ setFormData(prev => ({ ...prev, isActive: value }))} + label="فعال" + /> +
+ +
+ + +
+
+
+ ) +} + +export default CreateSliderModal + diff --git a/src/pages/setting/components/EditSliderModal.tsx b/src/pages/setting/components/EditSliderModal.tsx new file mode 100644 index 0000000..e1a3bc6 --- /dev/null +++ b/src/pages/setting/components/EditSliderModal.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react' +import { type FC } from 'react' +import Button from '@/components/Button' +import Input from '@/components/Input' +import DefaulModal from '@/components/DefaulModal' +import Select from '@/components/Select' +import { useUpdateSlider } from '../hooks/useSettingData' +import { useUploadSingle } from '@/pages/uploader/hooks/useUploaderData' +import { type SliderItem, type UpdateSliderType } from '../types/Types' +import { toast } from 'react-toastify' +import { extractErrorMessage } from '@/helpers/utils' +import UploadBox from '@/components/UploadBox' +import Switch from '@/components/Switch' + +interface EditSliderModalProps { + slider: SliderItem + isOpen: boolean + onClose: () => void + onSuccess: () => void +} + +const EditSliderModal: FC = ({ slider, isOpen, onClose, onSuccess }) => { + const [formData, setFormData] = useState({ + displayLocation: 'desktop', + imageUrl: '', + altText: '', + title: '', + linkUrl: '', + isActive: true, + }) + + const [imageFile, setImageFile] = useState([]) + + const updateSliderMutation = useUpdateSlider() + const uploadSingle = useUploadSingle() + + useEffect(() => { + if (slider) { + setFormData({ + displayLocation: slider.displayLocation, + imageUrl: slider.imageUrl, + altText: slider.altText, + title: slider.title, + linkUrl: slider.linkUrl, + isActive: slider.isActive, + }) + } + }, [slider]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.title || !formData.altText || !formData.linkUrl) { + toast.error('لطفا تمام فیلدهای الزامی را پر کنید') + return + } + + try { + let finalImageUrl = formData.imageUrl + + // Upload new image if selected + if (imageFile.length > 0) { + const uploadResponse = await uploadSingle.mutateAsync(imageFile[0]) + finalImageUrl = uploadResponse.results?.url?.url + + if (!finalImageUrl) { + toast.error('خطا در آپلود تصویر') + return + } + } + + // Update slider + await updateSliderMutation.mutateAsync({ + id: slider._id, + params: { + displayLocation: formData.displayLocation, + imageUrl: finalImageUrl, + altText: formData.altText, + title: formData.title, + linkUrl: formData.linkUrl, + isActive: formData.isActive, + } + }, { + onError: (error) => { + toast.error(extractErrorMessage(error)) + } + }) + + toast.success('اسلایدر با موفقیت بروزرسانی شد') + handleClose() + onSuccess() + } catch (error) { + toast.error('خطا در آپلود تصویر') + } + } + + const handleClose = () => { + setFormData({ + displayLocation: 'desktop', + imageUrl: '', + altText: '', + title: '', + linkUrl: '', + isActive: true, + }) + setImageFile([]) + onClose() + } + + const displayLocationOptions = [ + { value: 'desktop', label: 'دسکتاپ' }, + { value: 'mobile', label: 'موبایل' }, + ] + + return ( + +
+ {/* محل نمایش */} +
+ setFormData(prev => ({ ...prev, title: e.target.value }))} + placeholder="عنوان اسلایدر را وارد کنید" + required + /> +
+ + {/* متن جایگزین */} +
+ setFormData(prev => ({ ...prev, altText: e.target.value }))} + placeholder="توضیح تصویر برای دسترسی" + required + /> +
+ + {/* لینک */} +
+ setFormData(prev => ({ ...prev, linkUrl: e.target.value }))} + placeholder="/summer-sale" + required + /> +
+ + {/* وضعیت */} +
+ setFormData(prev => ({ ...prev, isActive: value }))} + label="فعال" + /> +
+ +
+ + +
+
+
+ ) +} + +export default EditSliderModal + diff --git a/src/pages/setting/hooks/useSettingData.ts b/src/pages/setting/hooks/useSettingData.ts index b34b3c5..0e0b42d 100644 --- a/src/pages/setting/hooks/useSettingData.ts +++ b/src/pages/setting/hooks/useSettingData.ts @@ -1,7 +1,7 @@ import * as api from "../service/SettingService"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FaqPageEnum, SiteSettingPageEnum } from "../enum/Enum"; -import { type CreateFaqType, type CreatePricingItem, type UpdateBannerType } from "../types/Types"; +import { type CreateFaqType, type CreatePricingItem, type UpdateBannerType, type UpdateSliderType } from "../types/Types"; export const useGetAboutUs = () => { return useQuery({ @@ -179,3 +179,36 @@ export const useCreateBanner = () => { }); }; +export const useGetSliders = () => { + return useQuery({ + queryKey: ["sliders"], + queryFn: api.getSliders, + }); +}; + +export const useDeleteSlider = () => { + return useMutation({ + mutationFn: api.deleteSlider, + }); +}; + +export const useUpdateSlider = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({id, params}: {id: string, params: UpdateSliderType}) => api.updateSlider(id, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sliders"] }); + }, + }); +}; + +export const useCreateSlider = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: api.createSlider, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sliders"] }); + }, + }); +}; + diff --git a/src/pages/setting/service/SettingService.ts b/src/pages/setting/service/SettingService.ts index de82f18..ee40399 100644 --- a/src/pages/setting/service/SettingService.ts +++ b/src/pages/setting/service/SettingService.ts @@ -6,14 +6,17 @@ import type { CreateDocumentType, CreateFaqType, CreatePricingItem, + CreateSliderType, DocumentTypesResponse, FaqDetailResponse, FaqResponse, PricingResponse, ShopResponse, + SlidersResponse, UpdateBannerType, UpdateShopType, UpdateSiteSetting, + UpdateSliderType, } from "../types/Types"; import { FaqPageEnum, SiteSettingPageEnum } from "../enum/Enum"; @@ -138,3 +141,23 @@ export const createBanner = async (params: UpdateBannerType) => { const { data } = await axios.post(`/admin/medias/banner`, params); return data; }; + +export const getSliders = async (): Promise => { + const { data } = await axios.get(`/admin/medias/slider`); + return data; +}; + +export const deleteSlider = async (id: string) => { + const { data } = await axios.delete(`/admin/medias/slider/${id}`); + return data; +}; + +export const updateSlider = async (id: string, params: UpdateSliderType) => { + const { data } = await axios.post(`/admin/medias/slider/${id}`, params); + return data; +}; + +export const createSlider = async (params: CreateSliderType) => { + const { data } = await axios.post(`/admin/medias/slider`, params); + return data; +}; diff --git a/src/pages/setting/types/Types.ts b/src/pages/setting/types/Types.ts index 7b5bf56..527df77 100644 --- a/src/pages/setting/types/Types.ts +++ b/src/pages/setting/types/Types.ts @@ -251,3 +251,43 @@ export interface UpdateBannerType { isActive: boolean; order: number; } + +export interface SliderItem { + _id: string; + displayLocation: "desktop" | "mobile"; + imageUrl: string; + altText: string; + title: string; + linkUrl: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SlidersResults { + sliders: SliderItem[]; +} + +export interface SlidersResponse { + status: number; + success: boolean; + results: SlidersResults; +} + +export interface CreateSliderType { + displayLocation: "desktop" | "mobile"; + imageUrl: string; + altText: string; + title: string; + linkUrl: string; + isActive: boolean; +} + +export interface UpdateSliderType { + displayLocation: "desktop" | "mobile"; + imageUrl: string; + altText: string; + title: string; + linkUrl: string; + isActive: boolean; +} diff --git a/src/router/Main.tsx b/src/router/Main.tsx index 33b47df..c9e6eeb 100644 --- a/src/router/Main.tsx +++ b/src/router/Main.tsx @@ -57,6 +57,7 @@ import ManageDocument from '@/pages/setting/ManageDocument' import Pricing from '@/pages/setting/Pricing' import Shop from '@/pages/setting/Shop' import Banners from '@/pages/setting/Banners' +import Sliders from '@/pages/setting/Sliders' import SiteSetting from '@/pages/setting/SiteSetting' import JobsList from '@/pages/jobs/List' import Resumes from '@/pages/jobs/Resume' @@ -161,6 +162,7 @@ const MainRouter: FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/shared/components/SettingsSubMenu.tsx b/src/shared/components/SettingsSubMenu.tsx index bdb8d35..21a654c 100644 --- a/src/shared/components/SettingsSubMenu.tsx +++ b/src/shared/components/SettingsSubMenu.tsx @@ -37,7 +37,12 @@ const SettingsSubMenu: FC = () => { +