From 9785078c8fdb516722202ed1ba742097f81c890d Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 27 Jan 2026 12:23:27 +0330 Subject: [PATCH] login --- package-lock.json | 11 ++ package.json | 1 + src/App.tsx | 8 +- src/assets/images/logo-small.svg | 5 + src/config/Paths.tsx | 1 + src/config/axios.ts | 2 +- src/config/func.ts | 20 +++ src/hooks/useCountDown.ts | 75 ++++++++ src/index.css | 28 ++- src/pages/auth/Login.tsx | 44 +++++ src/pages/auth/components/LoginStep1.tsx | 83 +++++++++ src/pages/auth/components/LoginStep2.tsx | 180 ++++++++++++++++++++ src/pages/auth/components/RegisterStep1.tsx | 77 +++++++++ src/pages/auth/components/RegisterStep2.tsx | 137 +++++++++++++++ src/pages/auth/hooks/useAuthData.tsx | 45 +++++ src/pages/auth/service/AuthService.ts | 50 ++++++ src/pages/auth/store/AuthStore.ts | 17 ++ src/pages/auth/types/AuthTypes.ts | 50 ++++++ src/router/AuthRouter.tsx | 5 +- 19 files changed, 832 insertions(+), 7 deletions(-) create mode 100644 src/assets/images/logo-small.svg create mode 100755 src/hooks/useCountDown.ts create mode 100644 src/pages/auth/Login.tsx create mode 100644 src/pages/auth/components/LoginStep1.tsx create mode 100644 src/pages/auth/components/LoginStep2.tsx create mode 100644 src/pages/auth/components/RegisterStep1.tsx create mode 100644 src/pages/auth/components/RegisterStep2.tsx create mode 100644 src/pages/auth/hooks/useAuthData.tsx create mode 100644 src/pages/auth/service/AuthService.ts create mode 100644 src/pages/auth/store/AuthStore.ts create mode 100644 src/pages/auth/types/AuthTypes.ts diff --git a/package-lock.json b/package-lock.json index c467efc..e5cf11f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.5.0", "react-multi-date-picker": "^4.5.2", + "react-otp-input": "^3.1.1", "react-router-dom": "^7.9.4", "react-spinners": "^0.17.0", "swiper": "^12.0.2", @@ -4574,6 +4575,16 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-otp-input": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-otp-input/-/react-otp-input-3.1.1.tgz", + "integrity": "sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6 || ^17.0.0 || ^18.0.0", + "react-dom": ">=16.8.6 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index e4fc476..b3cad04 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.5.0", "react-multi-date-picker": "^4.5.2", + "react-otp-input": "^3.1.1", "react-router-dom": "^7.9.4", "react-spinners": "^0.17.0", "swiper": "^12.0.2", diff --git a/src/App.tsx b/src/App.tsx index 2e6b024..5ad0655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,20 +9,22 @@ import MainRouter from './router/MainRouter' // import AuthRouter from './router/AuthRouter' import ToastContainer from './components/Toast' import { HelmetProvider } from 'react-helmet-async'; +import { getToken } from './config/func'; +import AuthRouter from './router/AuthRouter'; const queryClient = new QueryClient() const App: FC = () => { - // const isLoggedIn = getToken() + const isLoggedIn = getToken() + return ( - {/* {isLoggedIn ? : } */} + {isLoggedIn ? : } - diff --git a/src/assets/images/logo-small.svg b/src/assets/images/logo-small.svg new file mode 100644 index 0000000..23ede98 --- /dev/null +++ b/src/assets/images/logo-small.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/config/Paths.tsx b/src/config/Paths.tsx index b86071a..f454bb6 100644 --- a/src/config/Paths.tsx +++ b/src/config/Paths.tsx @@ -1,5 +1,6 @@ export const Paths = { home: '/home', + profile: '/profile', myOrders: '/my-orders', proformaInvoice: '/proforma-invoice', newOrder: '/new-order', diff --git a/src/config/axios.ts b/src/config/axios.ts index 37697f9..db6c869 100644 --- a/src/config/axios.ts +++ b/src/config/axios.ts @@ -9,7 +9,7 @@ const instance = axios.create({ instance.interceptors.request.use( (config) => { const token = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIwMUtGWjREWDhTNVJNOFMzMDc1NVZTWUNQUSIsImlhdCI6MTc2OTQ5NzYyMSwiZXhwIjoxNzY5NDk4ODIxfQ.YK6hQh3y-msWITy2sdLMsmwBvwXC5iHa3OxtWphyWQI" || + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIwMUtGWjREWDhTNVJNOFMzMDc1NVZTWUNQUSIsImlhdCI6MTc2OTUwMTE0OCwiZXhwIjoxNzY5NTAyMzQ4fQ.IOYPKCBPCUS9BtWdIDJWG1uG3nnVmLoInDirEZfsl6k" || getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; diff --git a/src/config/func.ts b/src/config/func.ts index f1fa83f..d779275 100644 --- a/src/config/func.ts +++ b/src/config/func.ts @@ -45,3 +45,23 @@ export const NumberFormat = (num?: number): string => { if (!num) return "0"; return num.toLocaleString("fa-IR"); }; + +export interface IGeneralError { + status: number; + success: boolean; + error: { + message: string[]; + }; +} + +export const extractErrorMessage = ( + error: Error | unknown, + fallbackMessage: string = "خطایی رخ داده است", +): string => { + try { + const axiosError = error as { response?: { data: IGeneralError } }; + return axiosError?.response?.data?.error?.message?.[0] || fallbackMessage; + } catch { + return fallbackMessage; + } +}; diff --git a/src/hooks/useCountDown.ts b/src/hooks/useCountDown.ts new file mode 100755 index 0000000..23e9a7b --- /dev/null +++ b/src/hooks/useCountDown.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; + +export interface ICountDown { + m: number; + s: number; +} +/** + * + * @param minutes how many minutes. + * @param autoplay by default true. if you want play coundDown after an action, you can set it to false and manually play it. + * @param onEnd you can pass a callback function that will trigger at the END. + * @returns + */ + +export function useCountDown( + minutes: number, + autoplay = true, + onEnd = () => {}, +) { + const [countDown, setCountDown] = useState({ m: minutes, s: 0 }); + const [stop, setStop] = useState(!autoplay); + + useEffect(() => { + const handler = setTimeout(() => { + if (countDown.m !== 0 || countDown.s !== 0) { + setCountDown(calculateCountDown(countDown)); + } else { + onEnd(); + } + }, 1000); + + return () => { + clearTimeout(handler); + }; + }, [countDown]); + + const reset = () => { + setCountDown({ m: minutes, s: 0 }); + }; + const pause = () => { + setStop(true); + }; + const play = () => { + setStop(false); + setCountDown({ ...countDown }); + }; + + const calculateCountDown = (st: ICountDown): ICountDown => { + if (stop) { + return countDown; + } + const timeLeft = { m: 0, s: 0 }; + const remain = st.m * 60 + st.s - 1; + if (remain > 0) { + timeLeft.m = Math.floor(remain / 60); + timeLeft.s = remain - timeLeft.m * 60; + return timeLeft; + } else { + if (remain === 0 && countDown.s === 1) { + return timeLeft; + } else { + return countDown; + } + } + }; + + return { + value: `${countDown.m > 9 ? countDown.m : "0" + countDown.m}:${ + countDown.s > 9 ? countDown.s : "0" + countDown.s + }`, + reset, + pause, + play, + }; +} diff --git a/src/index.css b/src/index.css index ed8974a..c9d4e1e 100644 --- a/src/index.css +++ b/src/index.css @@ -61,7 +61,9 @@ tbody tr:nth-child(odd) { width: 100% !important; margin: 0px; padding-right: 16px !important; - transition: background-color 0.3s ease, border-color 0.3s ease; + transition: + background-color 0.3s ease, + border-color 0.3s ease; } .readOny .rmdp-input { background-color: #f5f5f5 !important; @@ -75,3 +77,27 @@ tbody tr:nth-child(odd) { .rowTwoInput { @apply flex flex-col items-start sm:flex-row sm:gap-5 gap-5; } + +.rmdp-input { + min-height: 40px; + border-radius: 12px !important; + border: 1px solid #d0d0d0 !important; + font-size: 12px !important; + width: 100% !important; + margin: 0px; + padding-right: 16px !important; +} +.readOny .rmdp-input { + background-color: #f5f5f5 !important; + border: none !important; +} +.rmdp-container { + width: 100%; +} + +.dltr { + direction: ltr; +} +.drtl { + direction: rtl; +} diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx new file mode 100644 index 0000000..88445cc --- /dev/null +++ b/src/pages/auth/Login.tsx @@ -0,0 +1,44 @@ +import { type FC } from 'react' +import LogoImage from '../../assets/images/logo.svg' +import LogoSmallImage from '../../assets/images/logo-small.svg' +import { useAuthStore } from './store/AuthStore' +import LoginStep1 from './components/LoginStep1' +import LoginStep2 from './components/LoginStep2' + +const Login: FC = () => { + + const { stepLogin, phone } = useAuthStore() + + + return ( +
+
+
+
+ +
+ + + { + stepLogin === 1 ? + + : + stepLogin === 2 && !!phone ? + + : null + } +
+ + +
+
+ +
+ +
+
+
+ ) +} + +export default Login \ No newline at end of file diff --git a/src/pages/auth/components/LoginStep1.tsx b/src/pages/auth/components/LoginStep1.tsx new file mode 100644 index 0000000..f4db8b7 --- /dev/null +++ b/src/pages/auth/components/LoginStep1.tsx @@ -0,0 +1,83 @@ +import { type FC } from 'react' +import Input from '../../../components/Input' +import { type LoginType } from '../types/AuthTypes' +import { useFormik } from 'formik' +import * as Yup from 'yup' +import { useAuthStore } from '../store/AuthStore' +import Error from '../../../components/Error' +import Button from '../../../components/Button' +import { useCheckHasAccount, useLoginWithOtp } from '../hooks/useAuthData' +import { toast } from '@/shared/toast' +import { extractErrorMessage } from '@/config/func' + +const LoginStep1: FC = () => { + + const { phone, setPhone, setStepLogin } = useAuthStore() + const loginWithOtp = useLoginWithOtp() + const checkHasAccount = useCheckHasAccount() + + const formik = useFormik({ + initialValues: { + phone_email: phone, + }, + validationSchema: Yup.object({ + phone_email: Yup.string() + .required('این فیلد اجباری است.') + }), + onSubmit(values) { + setPhone(values.phone_email) + loginWithOtp.mutate({ phone: values.phone_email }, { + onSuccess() { + setStepLogin(2) + toast('کد تایید ارسال شد', 'success') + }, + onError(error) { + toast(extractErrorMessage(error), 'error') + }, + }) + }, + }) + + return ( +
+
+

+ خوش آمدید +

+

+ لطفا اطلاعات خود را وارد کنید. +

+
+ +
+ + + { + formik.touched.phone_email && formik.errors.phone_email && + + } + +
+ + +
+ ) +} + +export default LoginStep1 \ No newline at end of file diff --git a/src/pages/auth/components/LoginStep2.tsx b/src/pages/auth/components/LoginStep2.tsx new file mode 100644 index 0000000..5d4d7fa --- /dev/null +++ b/src/pages/auth/components/LoginStep2.tsx @@ -0,0 +1,180 @@ +import { type FC, useEffect } from 'react' +import { type OtpVerifyType } from '../types/AuthTypes' +import { useFormik } from 'formik' +import * as Yup from 'yup' +import { useAuthStore } from '../store/AuthStore' +import Button from '../../../components/Button' +import OTPInput from 'react-otp-input' +import { useCountDown } from '../../../hooks/useCountDown' +// import ArrowLeftIcon from '../../../assets/images/arrow-left.svg' +import { useLoginWithOtp, useOtpVerify } from '../hooks/useAuthData' +import { extractErrorMessage, setRefreshToken } from '../../../config/func' +import { setToken } from '../../../config/func' +import { toast } from '@/shared/toast' +import { Paths } from '@/config/Paths' + +const LoginStep2: FC = () => { + + const { phone, setStepLogin } = useAuthStore() + const { value, reset } = useCountDown(2, true) + const otpVerify = useOtpVerify() + const loginWithOtp = useLoginWithOtp() + + const formik = useFormik({ + initialValues: { + phone: phone, + otp: '' + }, + validationSchema: Yup.object({ + otp: Yup.string() + .required('این فیلد اجباری است.') + }), + onSubmit(values) { + const params: OtpVerifyType = { + phone: phone, + otp: values.otp + } + otpVerify.mutate(params, { + onSuccess(data) { + setToken(data?.data?.tokens?.accessToken?.token) + setRefreshToken(data?.data?.tokens?.refreshToken?.token) + // Check if there's a redirect parameter in the URL + window.location.href = Paths.profile + }, + onError(error) { + toast(extractErrorMessage(error), 'error') + }, + }) + }, + }) + + useEffect(() => { + // بررسی پشتیبانی از Web OTP API + if (!("OTPCredential" in window)) return; + + const ac = new AbortController(); + + navigator.credentials + .get({ + otp: { transport: ["sms"] }, + signal: ac.signal, + } as CredentialRequestOptions) // اطمینان از تطابق تایپ‌ها + .then((otpCredential) => { + if (otpCredential && "otp" in otpCredential) { + formik.setFieldValue("otp", (otpCredential).otp); + } + }) + .catch((err) => console.error("SMS AutoFill Error:", err)); + + return () => ac.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const reSend = () => { + loginWithOtp.mutate({ phone: phone }, { + onSuccess: () => { + reset() + toast('کد مجدد ارسال شد', 'success') + }, + onError: (error) => { + toast(extractErrorMessage(error), 'error') + } + }) + } + + useEffect(() => { + + if (formik.values.otp.length === 5) { + formik.handleSubmit() + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formik.values.otp]) + + + return ( +
+
+

+ کد تایید را وارد کنید +

+

+

+
کد تایید برای
+
{phone}
+
+ ارسال شد. +
+
+
setStepLogin(1)} className='text-black cursor-pointer'> + تغییر شماره +
+

+
+ +
+
+ کد تایید +
+
+ formik.setFieldValue('otp', otp)} + renderInput={(props) => + + } + /> +
+ + { + formik.touched.otp && formik.errors.otp && +
{formik.errors.otp}
+ } + +
+ { + value !== '00:00' ? +
+
{value}
+
مانده تا دریافت مجدد کد
+
+ : +
+
+ ارسال مجدد +
+
+ + } + +
+ +
+ + +
+ ) +} + +export default LoginStep2 \ No newline at end of file diff --git a/src/pages/auth/components/RegisterStep1.tsx b/src/pages/auth/components/RegisterStep1.tsx new file mode 100644 index 0000000..c07b7ab --- /dev/null +++ b/src/pages/auth/components/RegisterStep1.tsx @@ -0,0 +1,77 @@ +import { FC } from 'react' +import Input from '../../../components/Input' +import { useTranslation } from 'react-i18next' +import { CheckHasAccountPhoneType } from '../types/AuthTypes' +import { useFormik } from 'formik' +import * as Yup from 'yup' +import { useAuthStore } from '../store/AuthStore' +import Error from '../../../components/Error' +import Button from '../../../components/Button' +import { useCheckHasAccountRegister, useLoginWithOtp } from '../hooks/useAuthData' +import { toast } from '../../../components/Toast' +import { ErrorType } from '../../../helpers/types' + +const RegisterStep1: FC = () => { + + const { t } = useTranslation('global') + const { setPhone, setStepLogin } = useAuthStore() + const loginWithOtp = useLoginWithOtp() + const checkHasAccount = useCheckHasAccountRegister() + + const formik = useFormik({ + initialValues: { + phone: '', + }, + validationSchema: Yup.object({ + phone: Yup.string() + .required(t('errors.required')) + }), + onSubmit(values) { + checkHasAccount.mutate(values, { + onSuccess() { + setPhone(values.phone) + setStepLogin(2) + }, + onError(error: ErrorType) { + toast(error?.response?.data?.error?.message[0], 'error') + }, + }) + }, + }) + + return ( +
+ + +
+ + + { + formik.touched.phone && formik.errors.phone && + + } + +
+ + +
+ ) +} + +export default RegisterStep1 \ No newline at end of file diff --git a/src/pages/auth/components/RegisterStep2.tsx b/src/pages/auth/components/RegisterStep2.tsx new file mode 100644 index 0000000..5ef88c8 --- /dev/null +++ b/src/pages/auth/components/RegisterStep2.tsx @@ -0,0 +1,137 @@ +import { FC, Fragment } from 'react' +import { useFormik } from 'formik' +import { RegisterType } from '../types/AuthTypes' +import * as Yup from 'yup' +import { useAuthStore } from '../store/AuthStore' +import { useTranslation } from 'react-i18next' +import Input from '../../../components/Input' +import Button from '../../../components/Button' +import { useRegister } from '../hooks/useAuthData' +import { ErrorType } from '../../../helpers/types' +import { Pages } from '../../../config/Pages' +import { setToken } from '../../../config/func' +import { setRefreshToken } from '../../../config/func' +import { getRedirectUrl } from '../../../config/func' +import { toast } from '../../../components/Toast' +const RegisterStep2: FC = () => { + + const { t } = useTranslation('global') + const { phone } = useAuthStore() + const register = useRegister() + + const formik = useFormik({ + initialValues: { + firstName: '', + lastName: '', + code: 0, + password: '', + phone: phone, + }, + validationSchema: Yup.object({ + firstName: Yup.string() + .required(t('errors.required')), + lastName: Yup.string() + .required(t('errors.required')), + code: Yup.number() + .required(t('errors.required')), + password: Yup.string() + .required(t('errors.required')), + phone: Yup.string() + .required(t('errors.required')), + }), + onSubmit(values) { + register.mutate(values, { + onSuccess(data) { + setToken(data?.data?.accessToken?.token) + setRefreshToken(data?.data?.refreshToken?.token) + const redirectUrl = getRedirectUrl(); + if (redirectUrl) { + window.location.href = redirectUrl; + return; // Prevent the default navigation + } + window.location.href = Pages.dashboard + }, + onError(error: ErrorType) { + toast(error?.response?.data?.error?.message[0], 'error') + }, + }) + }, + }) + + + return ( + +
+ + + + +
+ {/*
+ formik.setFieldValue('birthDate', date)} + placeholder={t('auth.select_date')} + error_text={formik.touched.birthDate && formik.errors.birthDate ? formik.errors.birthDate : ''} + /> + + +
*/} + +
+ + +
+ +
+ +
+ +