From f7e4548c96dc7ae02d06c72f82cf4fbf658f9839 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Mon, 19 May 2025 14:59:49 +0330 Subject: [PATCH] create request company --- src/App.tsx | 10 +- src/components/Button.tsx | 3 +- src/components/UploadBox.tsx | 65 ++++- src/config/Pages.ts | 1 + src/config/axios.ts | 56 +++- src/hoc/withAuth.tsx | 5 - src/langs/fa.json | 37 ++- src/pages/request/Request.tsx | 263 ++++++++++++++++++ .../request/components/CompanyProducts.tsx | 92 ++++++ .../request/components/CompanyServices.tsx | 92 ++++++ src/pages/request/hooks/useRequestData.ts | 26 ++ src/pages/request/service/Service.ts | 22 ++ src/pages/request/types/Types.ts | 23 ++ src/router/Main.tsx | 6 +- 14 files changed, 677 insertions(+), 24 deletions(-) create mode 100644 src/pages/request/Request.tsx create mode 100644 src/pages/request/components/CompanyProducts.tsx create mode 100644 src/pages/request/components/CompanyServices.tsx create mode 100644 src/pages/request/hooks/useRequestData.ts create mode 100644 src/pages/request/service/Service.ts create mode 100644 src/pages/request/types/Types.ts diff --git a/src/App.tsx b/src/App.tsx index 4186bfa..4388e49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,7 @@ declare global { function App() { const queryClient = new QueryClient({ queryCache: new QueryCache({ - onError: async (error: IApiErrorRepsonse) => { + onError: async (error: IApiErrorRepsonse, query) => { if (error?.response?.status === 401) { try { // Use a flag to track if refresh is in progress @@ -63,8 +63,12 @@ function App() { await setRefreshToken(data.refreshToken?.token); } - // Retry the failed request - queryClient.invalidateQueries(); + // Retry the failed request, even if it was muted + if (query) { + queryClient.invalidateQueries({ queryKey: query.queryKey }); + } else { + queryClient.invalidateQueries(); + } return; } else { throw new Error('Invalid token response'); diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4a3f06b..cd55b88 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -6,7 +6,7 @@ type Props = { className?: string; isLoading?: boolean, } & ButtonHTMLAttributes & - XOR<{ children: ReactNode }, { label: string, icon?: string, isLoading?: boolean }>; + XOR<{ children: ReactNode }, { label: string, icon?: string }>; const Button: FC = memo((props: Props) => { const buttonClass = clx( 'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary text-white w-full', @@ -15,7 +15,6 @@ const Button: FC = memo((props: Props) => { ); return ( -
+
{ - file ? - file.name - : - t('no_select_file') + files?.map((item, index) => ( +
+ handleRemove(index)} size={16} color='red' /> +
{item.name}
+
+ )) }
+
+ { + files?.map((item, index) => ( +
+ handleRemove(index)} size={16} color='red' /> +
{item.name}
+
+ )) + } +
) } diff --git a/src/config/Pages.ts b/src/config/Pages.ts index 0d315c7..3d95a89 100644 --- a/src/config/Pages.ts +++ b/src/config/Pages.ts @@ -41,4 +41,5 @@ export const Pages = { reportCard: { list: "report-card/list", }, + request: "request", }; diff --git a/src/config/axios.ts b/src/config/axios.ts index 3c86697..58c9468 100644 --- a/src/config/axios.ts +++ b/src/config/axios.ts @@ -1,5 +1,13 @@ import axios from "axios"; -import { getToken } from "./func"; +import { + getToken, + getRefreshToken, + setToken, + setRefreshToken, + removeToken, + removeRefreshToken, +} from "./func"; +import { refreshToken } from "../pages/auth/service/AuthService"; // import { Pages } from "./Pages"; const axiosInstance = axios.create({ @@ -8,7 +16,51 @@ const axiosInstance = axios.create({ axiosInstance.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + const originalRequest = error.config; + + if (error?.response?.status === 401 && !originalRequest._retry) { + if (window.isRefreshingToken) { + // Wait for the refresh to complete + await new Promise((resolve) => { + const checkComplete = setInterval(() => { + if (!window.isRefreshingToken) { + clearInterval(checkComplete); + resolve(true); + } + }, 100); + }); + return axiosInstance(originalRequest); + } + + originalRequest._retry = true; + window.isRefreshingToken = true; + + try { + const refreshTokenValue = await getRefreshToken(); + const { data } = await refreshToken({ + refreshToken: refreshTokenValue || "", + }); + + if (data?.accessToken?.token) { + await setToken(data?.accessToken?.token); + if (data.refreshToken?.token) { + await setRefreshToken(data.refreshToken?.token); + } + + originalRequest.headers.Authorization = `Bearer ${data.accessToken.token}`; + return axiosInstance(originalRequest); + } + } catch (refreshError) { + console.error("Token refresh failed:", refreshError); + await removeToken(); + await removeRefreshToken(); + window.location.href = "/auth/login"; + return Promise.reject(refreshError); + } finally { + window.isRefreshingToken = false; + } + } return Promise.reject(error); } ); diff --git a/src/hoc/withAuth.tsx b/src/hoc/withAuth.tsx index 4aa8b23..eb5a2ed 100644 --- a/src/hoc/withAuth.tsx +++ b/src/hoc/withAuth.tsx @@ -3,12 +3,9 @@ import { useNavigate, useParams } from 'react-router-dom' import { getToken } from '../config/func' import { buildPath } from '../helpers/utils' -// تایپ برای کامپوننت‌های React type ComponentType = FC -// HOC برای محافظت از کامپوننت‌های نیازمند لاگین const withAuth = (WrappedComponent: ComponentType): ComponentType => { - // کامپوننت جدید که لاگین را چک می‌کند const WithAuthComponent: FC = (props) => { const navigate = useNavigate() const { slug } = useParams() @@ -25,11 +22,9 @@ const withAuth = (WrappedComponent: ComponentType): ComponentType => { checkAuth() }, [navigate, slug]) - // کامپوننت اصلی را با props خودش رندر کن return } - // نام نمایشی کامپوننت را برای دیباگ بهتر تنظیم کن WithAuthComponent.displayName = `WithAuth(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})` return WithAuthComponent diff --git a/src/langs/fa.json b/src/langs/fa.json index 55c0e7a..c064926 100644 --- a/src/langs/fa.json +++ b/src/langs/fa.json @@ -320,5 +320,40 @@ "title_course": "عنوان دوره" }, "status": "وضعیت", - "download": "دانلود" + "download": "دانلود", + "company": { + "count_company": "تعداد شرکت ها", + "company_name": "نام شرکت", + "ceo_name": "نام مدیر عامل", + "ceo_first_name": "نام مدیر عامل", + "ceo_last_name": "نام خانوادگی مدیر عامل", + "phone_number": "شماره تماس", + "email": "ایمیل", + "national_id": "شناسه ملی", + "establishment_date": "تاریخ تاسیس", + "address": "آدرس", + "map_link": "لینک نقشه شرکت", + "short_description": "توضیح کوتاه شرکت", + "website_url": "آدرس وبسایت", + "profile_image_url": "تصویر پروفایل", + "cover_image_url": "تصویر پوشه", + "is_active": "وضعیت فعالیت", + "industry_id": "صنعت", + "industry": "صنعت", + "profile_image": "تصویر پروفایل", + "cover_image": "تصویر کاور", + "created_at": "تاریخ ثبت", + "status": "وضعیت", + "plans": "پلن ها", + "detail": "جزییات", + "slider": "اسلایدر", + "category": "دسته بندی", + "company_developed": "شرکت توسعه دهنده", + "slug": "اسلاگ", + "industries": "صنعت ها", + "invoice_count": "تعداد صورتحساب", + "select_company": "انتخاب شرکت", + "register_date": "تاریخ ثبت نام", + "company": "شرکت" + } } diff --git a/src/pages/request/Request.tsx b/src/pages/request/Request.tsx new file mode 100644 index 0000000..55b923d --- /dev/null +++ b/src/pages/request/Request.tsx @@ -0,0 +1,263 @@ +import { FC } from 'react' +import withAuth from '../../hoc/withAuth' +import Button from '../../components/Button' +import { TickCircle } from 'iconsax-react' +import Input from '../../components/Input' +import { useTranslation } from 'react-i18next' +import { useFormik } from 'formik' +import * as Yup from 'yup' +import { useState } from 'react' +import { toast } from 'react-toastify' +import { ErrorType } from '../../helpers/types' +import { Pages } from '../../config/Pages' +import { useNavigate, useParams } from 'react-router-dom' +import DatePickerComponent from '../../components/DatePicker' +import moment from 'moment-jalaali' +import { useCreateCompany, useMultiUpload, useSingleUpload } from './hooks/useRequestData' +import { CreateCompanyType } from './types/Types' +import UploadBox from '../../components/UploadBox' +import CompanyServices from './components/CompanyServices' +import CompanyProducts from './components/CompanyProducts' +import { useGetIndustry } from '../home/hooks/useHomeData' +import Select from '../../components/Select' +import { IndustryItemType } from '../home/types/HomeTypes' +import { buildPath } from '../../helpers/utils' +const Request: FC = () => { + + const createCompany = useCreateCompany() + const navigate = useNavigate() + const { t } = useTranslation('global') + const [profileImage, setProfileImage] = useState(null) + const [coverImage, setCoverImage] = useState(null) + const multiUpload = useMultiUpload() + const singleUpload = useSingleUpload() + const { data: industries } = useGetIndustry() + const { slug } = useParams() + + const [services, setServices] = useState<{ title: string; image?: File }[]>([]) + const [products, setProducts] = useState<{ title: string; image?: File }[]>([]) + + const formik = useFormik({ + initialValues: { + phone: '', + name: '', + email: '', + identificationNumber: '', + dateOfEstablishment: '', + address: '', + mapAddressLink: '', + description: '', + websiteUrl: '', + profileImageUrl: '', + coverImageUrl: '', + industryId: '', + chiefExecutiveOfficer: '', + products: [], + services: [], + }, + validationSchema: Yup.object({ + phone: Yup.string().required(t('errors.required')), + name: Yup.string().required(t('errors.required')), + email: Yup.string().email(t('errors.invalid_email')).required(t('errors.required')), + identificationNumber: Yup.string().required(t('errors.required')), + dateOfEstablishment: Yup.string().required(t('errors.required')), + address: Yup.string().required(t('errors.required')), + mapAddressLink: Yup.string().url(t('errors.invalid_url')), + description: Yup.string().required(t('errors.required')), + websiteUrl: Yup.string().url(t('errors.invalid_url')).required(t('errors.required')), + industryId: Yup.string().required(t('errors.required')), + }), + onSubmit: async (values) => { + if (!profileImage || !coverImage) { + toast.error('لطفا تصاویر را انتخاب کنید') + return + } + + let serviceImages: string[] = [] + if (services.length > 0) { + const formDataServices = new FormData() + services.forEach(s => s.image && formDataServices.append('files', s.image)) + const serviceUploadRes = await multiUpload.mutateAsync(formDataServices) + serviceImages = serviceUploadRes.data.map((item: { url: string }) => item?.url) + } + + // آپلود تصاویر محصولات + let productImages: string[] = [] + if (products.length > 0) { + const formDataProducts = new FormData() + products.forEach(p => p.image && formDataProducts.append('files', p.image)) + const productUploadRes = await multiUpload.mutateAsync(formDataProducts) + productImages = productUploadRes.data.map((item: { url: string }) => item?.url) + } + + // مقداردهی به values + values.services = services.map((s, idx) => ({ + title: s.title, + imageUrl: serviceImages[idx] ? serviceImages[idx] : '' + })) + values.products = products.map((p, idx) => ({ + title: p.title, + imageUrl: productImages[idx] ? productImages[idx] : '' + })) + + const formDataProfile = new FormData() + formDataProfile.append('file', profileImage) + const profileUploadRes = await singleUpload.mutateAsync(formDataProfile) + values.profileImageUrl = profileUploadRes.data.url + + const formDataCover = new FormData() + formDataCover.append('file', coverImage) + const coverUploadRes = await singleUpload.mutateAsync(formDataCover) + values.coverImageUrl = coverUploadRes.data.url + + createCompany.mutate(values, { + onSuccess: () => { + toast.success('درخواست با موفقیت ثبت شد') + navigate(buildPath(Pages.dashboard, slug)) + }, + onError: (error: ErrorType) => { + toast.error(error.response?.data?.error.message[0]) + } + }) + toast.success('درخواست با موفقیت ثبت شد') + // navigate(Pages.request.list) + } + }) + + return ( +
+
+
+ ثبت درخواست +
+ +
+ +
+
+ +
+
اطلاعات درخواست
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + formik.setFieldValue('dateOfEstablishment', moment(date).format('YYYY-MM-DD'))} + error_text={formik.touched.dateOfEstablishment && formik.errors.dateOfEstablishment ? formik.errors.dateOfEstablishment : ''} + /> +
+ + + + + + + + + + setProfileImage(file[0])} + /> + + setCoverImage(file[0])} + /> + + + + +
+
+
+ ) +} + +export default withAuth(Request) \ No newline at end of file diff --git a/src/pages/request/components/CompanyProducts.tsx b/src/pages/request/components/CompanyProducts.tsx new file mode 100644 index 0000000..3cc05b7 --- /dev/null +++ b/src/pages/request/components/CompanyProducts.tsx @@ -0,0 +1,92 @@ +import { FC, useState } from 'react' +import Input from '../../../components/Input' +import UploadBox from '../../../components/UploadBox' +import { Add, CloseCircle } from 'iconsax-react' +import Button from '../../../components/Button' + +type Product = { title: string; image?: File; existingImageUrl?: string } + +type Props = { + value: Product[] + onChange: (val: Product[]) => void +} + +const CompanyProducts: FC = ({ value, onChange }) => { + const [productTitle, setProductTitle] = useState('') + const [productImage, setProductImage] = useState(undefined) + const [productError, setProductError] = useState('') + + const handleAddProduct = () => { + if (!productTitle.trim()) { + setProductError('عنوان محصول الزامی است') + return + } + if (!productImage) { + setProductError('تصویر محصول الزامی است') + return + } + if (value.some(s => s.title.trim() === productTitle.trim())) { + setProductError('عنوان محصول تکراری است') + return + } + onChange([...value, { title: productTitle.trim(), image: productImage }]) + setProductTitle('') + setProductImage(undefined) + setProductError('') + } + + const handleRemoveProduct = (idx: number) => { + onChange(value.filter((_, i) => i !== idx)) + } + + return ( +
+
محصولات شرکت
+
+
+ setProductTitle(e.target.value)} + /> +
+
+ setProductImage(files[0])} + isMultiple={false} + isReset={!productImage && !productTitle} + /> +
+ +
+ {productError &&
{productError}
} + {/* نمایش لیست محصولات */} +
+ {value.map((prd, idx) => ( +
+
+ {prd.image && } + {prd.existingImageUrl && !prd.image && } +
+
{prd.title}
+ +
+ ))} +
+
+ ) +} + +export default CompanyProducts \ No newline at end of file diff --git a/src/pages/request/components/CompanyServices.tsx b/src/pages/request/components/CompanyServices.tsx new file mode 100644 index 0000000..a1cb76d --- /dev/null +++ b/src/pages/request/components/CompanyServices.tsx @@ -0,0 +1,92 @@ +import { FC, useState } from 'react' +import Input from '../../../components/Input' +import UploadBox from '../../../components/UploadBox' +import { Add, CloseCircle } from 'iconsax-react' +import Button from '../../../components/Button' + +type Service = { title: string; image?: File; existingImageUrl?: string } + +type Props = { + value: Service[] + onChange: (val: Service[]) => void +} + +const CompanyServices: FC = ({ value, onChange }) => { + const [serviceTitle, setServiceTitle] = useState('') + const [serviceImage, setServiceImage] = useState(undefined) + const [serviceError, setServiceError] = useState('') + + const handleAddService = () => { + if (!serviceTitle.trim()) { + setServiceError('عنوان خدمت الزامی است') + return + } + if (!serviceImage) { + setServiceError('تصویر خدمت الزامی است') + return + } + if (value.some(s => s.title.trim() === serviceTitle.trim())) { + setServiceError('عنوان خدمت تکراری است') + return + } + onChange([...value, { title: serviceTitle.trim(), image: serviceImage }]) + setServiceTitle('') + setServiceImage(undefined) + setServiceError('') + } + + const handleRemoveService = (idx: number) => { + onChange(value.filter((_, i) => i !== idx)) + } + + return ( +
+
خدمات شرکت
+
+
+ setServiceTitle(e.target.value)} + /> +
+
+ setServiceImage(files[0])} + isMultiple={false} + isReset={!serviceImage && !serviceTitle} + /> +
+ +
+ {serviceError &&
{serviceError}
} + {/* نمایش لیست خدمات */} +
+ {value.map((srv, idx) => ( +
+
+ {srv.image && } + {srv.existingImageUrl && !srv.image && } +
+
{srv.title}
+ +
+ ))} +
+
+ ) +} + +export default CompanyServices \ No newline at end of file diff --git a/src/pages/request/hooks/useRequestData.ts b/src/pages/request/hooks/useRequestData.ts new file mode 100644 index 0000000..d299c56 --- /dev/null +++ b/src/pages/request/hooks/useRequestData.ts @@ -0,0 +1,26 @@ +import * as api from "../service/Service"; +import { useMutation } from "@tanstack/react-query"; +import { CreateCompanyType } from "../types/Types"; + +export const useCreateCompany = () => { + return useMutation({ + mutationFn: (data: CreateCompanyType) => api.createCompany(data), + }); +}; + +export const useSingleUpload = () => { + return useMutation({ + mutationFn: (variables: FormData) => api.uploadSingle(variables), + }); +}; +export const useMultiUpload = () => { + return useMutation({ + mutationFn: (variables: FormData) => api.uploadMultiple(variables), + }); +}; + +export const useCreateService = () => { + return useMutation({ + mutationFn: (variables: CreateCompanyType) => api.createCompany(variables), + }); +}; diff --git a/src/pages/request/service/Service.ts b/src/pages/request/service/Service.ts new file mode 100644 index 0000000..4968829 --- /dev/null +++ b/src/pages/request/service/Service.ts @@ -0,0 +1,22 @@ +import axios from "../../../config/axios"; +import { CreateCompanyType } from "../types/Types"; + +export const createCompany = async (data: CreateCompanyType) => { + const response = await axios.post("/companies/requests", data); + return response.data; +}; + +export const uploadSingle = async (params: FormData) => { + const { data } = await axios.post(`/uploader/single-file`, params); + return data; +}; + +export const uploadMultiple = async (params: FormData) => { + const { data } = await axios.post(`/uploader/multi-file`, params); + return data; +}; + +export const createService = async (params: CreateCompanyType) => { + const { data } = await axios.post("/companies/company-request", params); + return data; +}; diff --git a/src/pages/request/types/Types.ts b/src/pages/request/types/Types.ts new file mode 100644 index 0000000..28b49fa --- /dev/null +++ b/src/pages/request/types/Types.ts @@ -0,0 +1,23 @@ +export type CreateCompanyType = { + phone: string; + name: string; + email: string; + identificationNumber: string; + dateOfEstablishment: string; + address: string; + mapAddressLink: string; + description: string; + websiteUrl: string; + profileImageUrl: string; + coverImageUrl: string; + industryId: string; + products: { + title: string; + imageUrl: string; + }[]; + services: { + title: string; + imageUrl: string; + }[]; + chiefExecutiveOfficer: string; +}; diff --git a/src/router/Main.tsx b/src/router/Main.tsx index 47b280b..1e6e274 100644 --- a/src/router/Main.tsx +++ b/src/router/Main.tsx @@ -21,6 +21,7 @@ import { useEffect } from "react" import { useGetSlugId } from "../pages/home/hooks/useHomeData" import { ErrorType } from "../helpers/types" import { toast } from "react-toastify" +import Request from "../pages/request/Request" const MainRouter = () => { const { slug } = useParams() const getSlugId = useGetSlugId() @@ -47,12 +48,12 @@ const MainRouter = () => { }, [slug]) return ( -
+
-
+
} /> } /> @@ -69,6 +70,7 @@ const MainRouter = () => { } /> } /> } /> + } />