This commit is contained in:
hamid zarghami
2025-05-11 11:02:41 +03:30
parent 0341b836f7
commit 60750d9648
31 changed files with 5805 additions and 301 deletions
Vendored
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
VITE_TOKEN_NAME = 'dzone_token'
VITE_REFRESH_TOKEN_NAME = 'dzone_refresh_token'
# VITE_BASE_URL = 'https://api.danakcorp.com'
VITE_BASE_URL = 'http://192.168.1.109:4000'
+1
View File
@@ -19,6 +19,7 @@ export default tseslint.config(
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': false,
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': [
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
+349 -263
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -17,12 +17,14 @@
"formik": "^2.4.6", "formik": "^2.4.6",
"i18next": "^24.2.1", "i18next": "^24.2.1",
"iconsax-react": "^0.0.8", "iconsax-react": "^0.0.8",
"js-cookie": "^3.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-loading": "^2.0.3", "react-loading": "^2.0.3",
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"react-otp-input": "^3.1.1",
"react-router-dom": "^7.1.3", "react-router-dom": "^7.1.3",
"react-toastify": "^11.0.3", "react-toastify": "^11.0.3",
"swiper": "^11.2.1", "swiper": "^11.2.1",
@@ -32,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/swiper": "^5.4.3", "@types/swiper": "^5.4.3",
@@ -46,5 +49,6 @@
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.2", "typescript-eslint": "^8.18.2",
"vite": "^6.0.5" "vite": "^6.0.5"
} },
"packageManager": "pnpm@10.8.1+sha512.c50088ba998c67b8ca8c99df8a5e02fd2ae2e2b29aaf238feaa9e124248d3f48f9fb6db2424949ff901cffbb5e0f0cc1ad6aedb602cd29450751d11c35023677"
} }
+4124
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+112 -7
View File
@@ -5,6 +5,15 @@ import { I18nextProvider } from 'react-i18next'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import 'swiper/swiper-bundle.css'; import 'swiper/swiper-bundle.css';
import FaJson from './langs/fa.json' import FaJson from './langs/fa.json'
import { useState } from 'react';
import { Pages } from './config/Pages';
import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from './config/func';
import { useEffect } from 'react';
import AuthRouter from './router/Auth';
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IApiErrorRepsonse } from './types/error.types';
import { refreshToken } from './pages/auth/service/AuthService';
import ToastContainer from './components/Toast';
i18next.init({ i18next.init({
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
lng: 'fa', lng: 'fa',
@@ -15,16 +24,112 @@ i18next.init({
} }
}) })
declare global {
interface Window {
isRefreshingToken?: boolean;
}
}
function App() { function App() {
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
useEffect(() => {
const checkLogin = async () => {
const token = await getToken();
if (token) {
setIsLogin('isLogin')
} else {
setIsLogin('isNotLogin')
if (window.location.href.split('auth').length === 1) {
window.location.href = Pages.auth.login
}
}
}
checkLogin();
}, []);
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: async (error: IApiErrorRepsonse) => {
if (error?.response?.status === 401) {
try {
// Use a flag to track if refresh is in progress
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;
}
// Set the flag to indicate refresh is in progress
window.isRefreshingToken = true;
try {
const refreshTokenValue = await getRefreshToken();
const { data } = await refreshToken({ refreshToken: refreshTokenValue || '' });
if (data?.accessToken?.token) {
// Save the new token
await setToken(data?.accessToken?.token);
// If refresh token also returned, update it
if (data.refreshToken?.token) {
await setRefreshToken(data.refreshToken?.token);
}
// Retry the failed request
queryClient.invalidateQueries();
return;
} else {
throw new Error('Invalid token response');
}
} finally {
window.isRefreshingToken = false;
}
} catch (refreshError: unknown) {
console.error('Token refresh failed:', refreshError);
// Clear tokens and redirect to login
await removeToken();
await removeRefreshToken();
window.location.href = '/auth/login';
}
}
}
}),
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false
// staleTime: 86400000 // 1 day in milliseconds
}
}
});
return ( return (
<> <>
<BrowserRouter> <BrowserRouter>
<I18nextProvider i18n={i18next}> <QueryClientProvider client={queryClient}>
<MainRouter /> <I18nextProvider i18n={i18next}>
</I18nextProvider> {
</BrowserRouter> isLogin === 'checking' ?
</> null
:
isLogin === 'isLogin' ?
<MainRouter />
:
<AuthRouter />
}
<ToastContainer />
</I18nextProvider>
</QueryClientProvider>
</BrowserRouter>
</>
+73
View File
@@ -0,0 +1,73 @@
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
import React, { useState, useEffect } from 'react';
interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'info';
isExiting?: boolean;
}
let addToast: (toast: Toast) => void;
const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
// ثبت toast جدید
const showToast = (toast: Toast) => {
setToasts((prev) => [...prev, toast]);
// حذف خودکار بعد از ۳ ثانیه
setTimeout(() => {
setToasts((prev) => prev.map(t =>
t.id === toast.id ? { ...t, isExiting: true } : t
));
// حذف از DOM بعد از اتمام انیمیشن
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 300); // مدت زمان انیمیشن خروج
}, 3000);
};
// تخصیص تابع نمایش toast به متغیر سراسری
useEffect(() => {
addToast = showToast;
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md
${toast.isExiting ? 'animate-slide-out-right' : 'animate-slide-in-right'} ${toast.type === 'success'
? 'bg-white/70 text-black'
: toast.type === 'error'
? 'bg-white/70 text-black'
: 'bg-white/70 text-black'
}`}
style={{
animationFillMode: 'forwards',
animationDuration: '0.3s',
animationTimingFunction: 'ease-out'
}}
>
{
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
<InfoCircle className='size-5' color='blue' />
}
{toast.message}
</div>
))}
</div>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const toast = (message?: string, type: 'success' | 'error' | 'info' = 'info') => {
addToast({ id: Date.now().toString(), message: message || '', type });
};
export default ToastContainer;
+4 -8
View File
@@ -1,5 +1,6 @@
import axios from "axios"; import axios from "axios";
import { Pages } from "./Pages"; import { getToken } from "./func";
// import { Pages } from "./Pages";
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL, baseURL: import.meta.env.VITE_BASE_URL,
@@ -8,18 +9,13 @@ const axiosInstance = axios.create({
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response.status === 401) {
localStorage.removeItem(import.meta.env.VITE_TOKEN_NAME);
window.location.href = Pages.auth.login;
}
return Promise.reject(error); return Promise.reject(error);
} }
); );
axiosInstance.interceptors.request.use(async function (config) { axiosInstance.interceptors.request.use(async function (config) {
const token = localStorage.getItem(import.meta.env.VITE_TOKEN_NAME) const tokenValue = await getToken();
? localStorage.getItem(import.meta.env.VITE_TOKEN_NAME) const token = tokenValue ? tokenValue : "";
: "";
config.headers.Authorization = "Bearer " + token; config.headers.Authorization = "Bearer " + token;
return config; return config;
+65
View File
@@ -0,0 +1,65 @@
import Cookie from "js-cookie";
export function isEmail(input: string): boolean {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(input);
}
export function NumberFormat(number: number): string {
return Intl.NumberFormat("fa-IR").format(number);
// return Intl.NumberFormat("fa-IR").format(number);
}
export const timeAgo = (date: string | Date): string => {
const now = new Date();
const past = new Date(date);
const diff = Math.floor((now.getTime() - past.getTime()) / 1000); // اختلاف بر حسب ثانیه
const units = [
{ name: "سال", value: 60 * 60 * 24 * 365 },
{ name: "ماه", value: 60 * 60 * 24 * 30 },
{ name: "روز", value: 60 * 60 * 24 },
{ name: "ساعت", value: 60 * 60 },
{ name: "دقیقه", value: 60 },
{ name: "ثانیه", value: 1 },
];
for (const unit of units) {
const amount = Math.floor(diff / unit.value);
if (amount >= 1) return `${amount} ${unit.name} پیش`;
}
return "همین الان";
};
export const getToken = () => {
return Cookie.get(import.meta.env.VITE_TOKEN_NAME);
};
export const getRefreshToken = () => {
return Cookie.get(import.meta.env.VITE_REFRESH_TOKEN_NAME);
};
const isProduction = import.meta.env.MODE === "production";
const domain = isProduction ? ".danakcorp.com" : undefined;
export const setToken = (token: string) => {
Cookie.set(import.meta.env.VITE_TOKEN_NAME, token, { domain });
};
export const setRefreshToken = (refreshToken: string) => {
Cookie.set(import.meta.env.VITE_REFRESH_TOKEN_NAME, refreshToken, { domain });
};
export const removeToken = () => {
Cookie.remove(import.meta.env.VITE_TOKEN_NAME, { domain, path: "/" });
};
export const removeRefreshToken = () => {
Cookie.remove(import.meta.env.VITE_REFRESH_TOKEN_NAME, { domain, path: "/" });
};
export const getRedirectUrl = () => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get("redirect");
};
+13 -3
View File
@@ -5,6 +5,16 @@ export type XOR<T, U> = T | U extends object
: T | U; : T | U;
export type ItemsSelectType = { export type ItemsSelectType = {
value: any, value: string;
label: string, label: string;
} };
export type ErrorType = Error & {
response?: {
data?: {
error: {
message: string[];
};
};
};
};
+76
View File
@@ -0,0 +1,76 @@
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<ICountDown>({ m: minutes, s: 0 });
const [stop, setStop] = useState<boolean>(!autoplay);
useEffect(() => {
const handler = setTimeout(() => {
if (countDown.m !== 0 || countDown.s !== 0) {
setCountDown(calculateCountDown(countDown));
} else {
onEnd();
}
}, 1000);
return () => {
clearTimeout(handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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,
};
}
+4 -1
View File
@@ -66,7 +66,10 @@
"enter_password_step3": "رمز عبور را وارد کنید", "enter_password_step3": "رمز عبور را وارد کنید",
"enter_your_password": "رمز عبور خود را وارد کنید.", "enter_your_password": "رمز عبور خود را وارد کنید.",
"forgot_password": "فراموشی رمز عبور", "forgot_password": "فراموشی رمز عبور",
"login_with_otp": "ورود با رمز عبور یکبار مصرف" "login_with_otp": "ورود با رمز عبور یکبار مصرف",
"mobile_number": "شماره موبایل",
"enter_mobile_number": "شماره موبایل خود را وارد کنید",
"enter_verify_code": "کد تایید خود را وارد کنید"
}, },
"errors": { "errors": {
"required": "این فیلد اجباری می باشد" "required": "این فیلد اجباری می باشد"
BIN
View File
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
import { 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'
import LoginStep3 from './components/LoginStep3'
const Login: FC = () => {
const { stepLogin, phone, email } = useAuthStore()
return (
<div className='w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4'>
<div className='flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden'>
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
<div className='flex-1 h-full flex flex-col'>
<div className='flex relative flex-1 flex-col h-full justify-center'>
<img src={LogoSmallImage} className='w-8 hidden xl:block' />
<img src={LogoImage} className='w-44 right-0 left-0 mx-auto absolute top-10 xl:hidden' />
{
stepLogin === 1 ?
<LoginStep1 />
:
stepLogin === 2 && !!phone ?
<LoginStep2 />
: stepLogin === 2 && !!email ?
<LoginStep3 />
: stepLogin === 3 ?
<LoginStep3 />
: null
}
</div>
</div>
</div>
<div className='flex-1 min-w-[50%] lg:flex hidden justify-center items-center'>
<img src={LogoImage} className='h-20' />
</div>
</div>
</div>
)
}
export default Login
+70
View File
@@ -0,0 +1,70 @@
import { FC } from 'react'
import LogoImage from '../../assets/images/logo.svg'
import LogoSmallImage from '../../assets/images/logo-small.svg'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { Pages } from '../../config/Pages'
import RegisterStep1 from './components/RegisterStep1'
import { useAuthStore } from './store/AuthStore'
import RegisterStep2 from './components/RegisterStep2'
const Register: FC = () => {
const { stepLogin } = useAuthStore()
const { t } = useTranslation('global')
return (
<div className='w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4'>
<div className='flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden'>
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
<div className='flex-1 h-full flex flex-col'>
<div className='flex relative flex-1 flex-col h-full justify-center'>
<img src={LogoSmallImage} className='w-8 hidden xl:block' />
<div className=''>
<img src={LogoImage} className='w-44 mx-auto my-20 xl:hidden' />
</div>
<div className=''>
<div className='mt-5'>
<h2 className='text-2xl font-bold'>
{t('auth.welcome')}
</h2>
<p className='text-description text-sm mt-2'>
{t('auth.enter_information')}
</p>
</div>
{
stepLogin === 1 ?
<RegisterStep1 />
:
<RegisterStep2 />
}
<div className='mt-6 flex justify-center gap-2 items-center text-sm'>
<p className='text-description'>
{t('auth.before_registered')}
</p>
<Link to={Pages.auth.login}>
<div>{t('auth.login')}</div>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className='flex-1 min-w-[50%] lg:flex hidden justify-center items-center'>
<img src={LogoImage} className='h-20' />
</div>
</div>
</div>
)
}
export default Register
+108
View File
@@ -0,0 +1,108 @@
import { FC } from 'react'
import Input from '../../../components/Input'
import { useTranslation } from 'react-i18next'
import { 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 { Link } from 'react-router-dom'
import { Pages } from '../../../config/Pages'
import { isEmail } from '../../../config/func'
import { useCheckHasAccount, useLoginWithOtp } from '../hooks/useAuthData'
import { ErrorType } from '../../../helpers/types'
import { toast } from '../../../components/Toast'
const LoginStep1: FC = () => {
const { t } = useTranslation('global')
const { setPhone, phone, setStepLogin, setEmail } = useAuthStore()
const loginWithOtp = useLoginWithOtp()
const checkHasAccount = useCheckHasAccount()
const formik = useFormik<LoginType>({
initialValues: {
phone_email: phone,
},
validationSchema: Yup.object({
phone_email: Yup.string()
.required(t('errors.required'))
}),
onSubmit(values) {
if (isEmail(values.phone_email)) {
setEmail(values.phone_email)
checkHasAccount.mutate({ email: values.phone_email }, {
onSuccess() {
setStepLogin(2)
},
onError(error: ErrorType) {
toast(error.response?.data?.error.message?.[0], 'error')
},
})
} else {
setPhone(values.phone_email)
loginWithOtp.mutate({ phone: values.phone_email }, {
onSuccess() {
setStepLogin(2)
toast(t('auth.otp_sent'), 'success')
},
onError(error: ErrorType) {
toast(error?.response?.data?.error?.message[0], 'error')
},
})
}
},
})
return (
<div>
<div className='mt-5'>
<h2 className='text-2xl font-bold'>
{t('auth.welcome')}
</h2>
<p className='text-description text-sm mt-2'>
{t('auth.enter_info_login')}
</p>
</div>
<div className='mt-16'>
<Input
label={t('auth.mobile_or_email')}
placeholder={t('auth.enter_mobile_or_email')}
type='text'
className='text-right'
name='phone_email'
onChange={formik.handleChange}
value={formik.values.phone_email}
/>
{
formik.touched.phone_email && formik.errors.phone_email &&
<Error
errorText={formik.errors.phone_email}
/>
}
</div>
<Button
label={t('auth.next')}
className='mt-8'
onClick={() => formik.handleSubmit()}
isLoading={loginWithOtp.isPending || checkHasAccount.isPending}
/>
<div className='mt-6 flex justify-center gap-2 items-center text-sm'>
<p className='text-description'>
{t('auth.have_account')}
</p>
<Link to={Pages.auth.register}>
<div>{t('auth.register')}</div>
</Link>
</div>
</div>
)
}
export default LoginStep1
+186
View File
@@ -0,0 +1,186 @@
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { 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 { Pages } from '../../../config/Pages'
import { useLoginWithOtp, useOtpVerify } from '../hooks/useAuthData'
import { ErrorType } from '../../../helpers/types'
import { setRefreshToken } from '../../../config/func'
import { setToken } from '../../../config/func'
import { getRedirectUrl } from '../../../config/func'
import { toast } from '../../../components/Toast'
const LoginStep2: FC = () => {
const { t } = useTranslation('global')
const { phone, setStepLogin } = useAuthStore()
const { value, reset } = useCountDown(2, true)
const otpVerify = useOtpVerify()
const loginWithOtp = useLoginWithOtp()
const formik = useFormik<OtpVerifyType>({
initialValues: {
phone: phone,
code: ''
},
validationSchema: Yup.object({
code: Yup.string()
.required(t('errors.required'))
}),
onSubmit(values) {
const params: OtpVerifyType = {
phone: phone,
code: values.code
}
otpVerify.mutate(params, {
onSuccess(data) {
setToken(data?.data?.accessToken?.token)
setRefreshToken(data?.data?.refreshToken?.token)
// Check if there's a redirect parameter in the URL
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')
},
})
},
})
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 && "code" in otpCredential) {
formik.setFieldValue("code", (otpCredential).code);
}
})
.catch((err) => console.error("SMS AutoFill Error:", err));
return () => ac.abort();
}, []);
const reSend = () => {
loginWithOtp.mutate({ phone: phone }, {
onSuccess: () => {
reset()
toast(t('auth.toast_resend_code'), 'success')
},
onError: (error: ErrorType) => {
toast(error.response?.data?.error.message[0], 'error')
}
})
}
useEffect(() => {
if (formik.values.code.length === 5) {
formik.handleSubmit()
}
}, [formik.values.code])
return (
<div>
<div className='mt-5'>
<h2 className='lg:text-2xl font-bold'>
{t('auth.enter_otp')}
</h2>
<p className='text-description flex lg:flex-row flex-col lg:gap-1 gap-2 text-sm lg:mt-2 mt-3'>
<div className='flex gap-1'>
<div>{t('auth.verfify_code_text_1')}</div>
<div>{phone}</div>
<div>
{t('auth.verfify_code_text_2')}
</div>
</div>
<div onClick={() => setStepLogin(1)} className='text-black cursor-pointer'>
{t('auth.change_number')}
</div>
</p>
</div>
<div className='mt-16'>
<div className='text-sm'>
{t('auth.verify_code')}
</div>
<div className='mt-2 w-full flex justify-center dltr otp'>
<OTPInput
shouldAutoFocus
value={formik.values.code}
numInputs={5}
inputType='tel'
onChange={(otp: string) => formik.setFieldValue('code', otp)}
renderInput={(props) =>
<input
{...props}
type='tel'
autoComplete="one-time-code"
inputMode="numeric"
className='w-full h-[50px] flex-1 mx-2 bg-white border rounded-2.5' />
}
/>
</div>
{
formik.touched.code && formik.errors.code &&
<div className='text-xs mt-2 text-red-500'>{formik.errors.code}</div>
}
<div className='flex mt-4 justify-end'>
{
value !== '00:00' ?
<div className='flex gap-1 text-description text-xs'>
<div>{value}</div>
<div>{t('auth.counter_otp')}</div>
</div>
:
<div onClick={reSend} className='flex cursor-pointer gap-1 pl-2 text-black text-sm'>
<div className=''>
{t('auth.try_again')}
</div>
</div>
}
</div>
</div>
<Button
label={t('auth.login')}
className='mt-8'
onClick={() => formik.handleSubmit()}
isLoading={otpVerify.isPending}
/>
{/* <div onClick={() => setStepLogin(3)} className='mt-6 cursor-pointer flex gap-2 items-center text-sm'>
<p className='text-description'>
{t('auth.login_with_password')}
</p>
<img src={ArrowLeftIcon} className='w-5' />
</div> */}
</div>
)
}
export default LoginStep2
+108
View File
@@ -0,0 +1,108 @@
import { FC } from 'react'
import Input from '../../../components/Input'
import { useTranslation } from 'react-i18next'
import { LoginWithPasswordType } 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 { Link } from 'react-router-dom'
import { Pages } from '../../../config/Pages'
import ArrowLeftIcon from '../../../assets/images/arrow-left.svg'
import { useLoginWithPassword } from '../hooks/useAuthData'
import { ErrorType } from '../../../helpers/types'
import { setRefreshToken } from '../../../config/func'
import { setToken } from '../../../config/func'
import { getRedirectUrl } from '../../../config/func'
import { toast } from '../../../components/Toast'
const LoginStep3: FC = () => {
const { t } = useTranslation('global')
const { setStepLogin, email, phone } = useAuthStore()
const loginWithPassword = useLoginWithPassword()
const formik = useFormik<LoginWithPasswordType>({
initialValues: {
password: '',
email: email ? email : undefined,
phone: phone ? phone : undefined,
},
validationSchema: Yup.object({
password: Yup.string()
.required(t('errors.required'))
}),
onSubmit(values) {
loginWithPassword.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 (
<div>
<div className='mt-5'>
<h2 className='text-2xl font-bold'>
{t('auth.enter_password_step3')}
</h2>
<p className='text-description text-sm mt-2'>
{t('auth.enter_your_password')}
</p>
</div>
<div className='mt-16'>
<Input
label={t('auth.password')}
type='password'
className='text-right'
name='password'
onChange={formik.handleChange}
value={formik.values.password}
error_text={formik.touched.password && formik.errors.password ? formik.errors.password : ''}
/>
{
formik.touched.password && formik.errors.password &&
<Error
errorText={formik.errors.password}
/>
}
</div>
<Link to={Pages.auth.forgotPassword}>
<div className='mt-4 flex justify-end text-sm'>
{t('auth.forgot_password')}
</div>
</Link>
<Button
label={t('auth.login')}
className='mt-8'
onClick={() => formik.handleSubmit()}
/>
<div onClick={() => setStepLogin(2)} className='mt-6 cursor-pointer flex gap-2 items-center text-sm'>
<p className='text-description'>
{t('auth.login_with_otp')}
</p>
<img src={ArrowLeftIcon} className='w-4' />
</div>
</div>
)
}
export default LoginStep3
@@ -0,0 +1,78 @@
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<CheckHasAccountPhoneType>({
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 (
<div className='flex-1'>
<div className='mt-16'>
<Input
label={t('auth.mobile_number')}
placeholder={t('auth.enter_mobile_number')}
type='tel'
className='text-right'
name='phone'
onChange={formik.handleChange}
value={formik.values.phone}
/>
{
formik.touched.phone && formik.errors.phone &&
<Error
errorText={formik.errors.phone}
/>
}
</div>
<Button
label={t('auth.next')}
className='mt-8'
onClick={() => formik.handleSubmit()}
isLoading={loginWithOtp.isPending || checkHasAccount.isPending}
/>
</div>
)
}
export default RegisterStep1
+143
View File
@@ -0,0 +1,143 @@
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 DatePickerComponent from '../../../components/DatePicker'
import Button from '../../../components/Button'
import { useRegister } from '../hooks/useAuthData'
import { ErrorType } from '../../../helpers/types'
import { toast } from 'react-toastify'
import { Pages } from '../../../config/Pages'
import { setToken } from '../../../config/func'
import { setRefreshToken } from '../../../config/func'
import { getRedirectUrl } from '../../../config/func'
const RegisterStep2: FC = () => {
const { t } = useTranslation('global')
const { phone } = useAuthStore()
const register = useRegister()
const formik = useFormik<RegisterType>({
initialValues: {
firstName: '',
lastName: '',
birthDate: '',
code: 0,
nationalCode: '',
password: '',
phone: phone,
},
validationSchema: Yup.object({
firstName: Yup.string()
.required(t('errors.required')),
lastName: Yup.string()
.required(t('errors.required')),
birthDate: Yup.string()
.required(t('errors.required')),
code: Yup.number()
.required(t('errors.required')),
nationalCode: Yup.string()
.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(error?.response?.data?.error?.message[0])
},
})
},
})
return (
<Fragment>
<div className='mt-5 rowTwoInput'>
<Input
label={t('auth.name')}
placeholder={t('auth.enter_name')}
name='firstName'
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''}
/>
<Input
label={t('auth.family')}
placeholder={t('auth.enter_family')}
name='lastName'
onChange={formik.handleChange}
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
/>
</div>
<div className='mt-4 min-w-full rowTwoInput'>
<DatePickerComponent
label={t('auth.dateofbirth')}
onChange={(date: string) => formik.setFieldValue('birthDate', date)}
placeholder={t('auth.select_date')}
error_text={formik.touched.birthDate && formik.errors.birthDate ? formik.errors.birthDate : ''}
/>
<Input
label={t('auth.national_code')}
placeholder={t('auth.national_code_enter')}
name='nationalCode'
onChange={formik.handleChange}
error_text={formik.touched.nationalCode && formik.errors.nationalCode ? formik.errors.nationalCode : ''}
/>
</div>
<div className='mt-4 rowTwoInput'>
<Input
label={t('auth.password')}
placeholder={t('auth.enter_password')}
type='password'
name='password'
onChange={formik.handleChange}
error_text={formik.touched.password && formik.errors.password ? formik.errors.password : ''}
/>
<Input
label={t('auth.phonen_number')}
placeholder={t('auth.enter_phone_number')}
readOnly
value={phone}
/>
</div>
<div className='mt-4 rowTwoInput'>
<Input
label={t('auth.verify_code')}
placeholder={t('auth.enter_verify_code')}
type='tel'
name='code'
onChange={formik.handleChange}
error_text={formik.touched.code && formik.errors.code ? formik.errors.code : ''}
/>
</div>
<Button
label={t('auth.next')}
className='mt-8'
onClick={() => formik.handleSubmit()}
isLoading={register.isPending}
/>
</Fragment>
)
}
export default RegisterStep2
+45
View File
@@ -0,0 +1,45 @@
import { useMutation } from '@tanstack/react-query';
import * as api from '../service/AuthService'
import { CheckHasAccountPhoneType, CheckHasAccountType, LoginWithOtpType, LoginWithPasswordType, OtpVerifyType, RegisterType } from '../types/AuthTypes';
export const useLoginWithPassword = () => {
return useMutation({
mutationFn: (variables: LoginWithPasswordType) => api.loginWithPassword(variables),
});
};
export const useLoginWithOtp = () => {
return useMutation({
mutationFn: (variables: LoginWithOtpType) => api.loginWithOtp(variables),
});
};
export const useOtpVerify = () => {
return useMutation({
mutationFn: (variables: OtpVerifyType) => api.otpVerify(variables),
});
};
export const useCheckHasAccount = () => {
return useMutation({
mutationFn: (variables: CheckHasAccountType) => api.checkHasAccount(variables),
});
};
export const useCheckHasAccountRegister = () => {
return useMutation({
mutationFn: (variables: CheckHasAccountPhoneType) => api.checkHasAccountRegister(variables),
});
};
export const useRegister = () => {
return useMutation({
mutationFn: (variables: RegisterType) => api.register(variables),
});
};
export const useLogout = () => {
return useMutation({
mutationFn: (_) => api.logout(),
});
};
+50
View File
@@ -0,0 +1,50 @@
import axios from "../../../config/axios";
import {
CheckHasAccountPhoneType,
CheckHasAccountType,
LoginWithOtpType,
LoginWithPasswordType,
OtpVerifyType,
RefreshTokenType,
RegisterType,
} from "../types/AuthTypes";
export const loginWithPassword = async (params: LoginWithPasswordType) => {
const { data } = await axios.post(`/auth/login/password`, params);
return data;
};
export const loginWithOtp = async (params: LoginWithOtpType) => {
const { data } = await axios.post(`/auth/otp/send`, params);
return data;
};
export const otpVerify = async (params: OtpVerifyType) => {
const { data } = await axios.post(`/auth/otp/verify`, params);
return data;
};
export const checkHasAccount = async (params: CheckHasAccountType) => {
const { data } = await axios.post(`/auth/check`, params);
return data;
};
export const checkHasAccountRegister = async (
params: CheckHasAccountPhoneType
) => {
const { data } = await axios.post(`/auth/register/initiate`, params);
return data;
};
export const register = async (params: RegisterType) => {
const { data } = await axios.post(`/auth/register/complete`, params);
return data;
};
export const refreshToken = async (params: RefreshTokenType) => {
const { data } = await axios.post(`/auth/refresh`, params);
return data;
};
export const logout = async () => {
const { data } = await axios.post(`/auth/logout`);
return data;
};
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
import { AuthStoreType } from "../../auth/types/AuthTypes";
export const useAuthStore = create<AuthStoreType>((set) => ({
phone: "",
setPhone(value) {
set({ phone: value });
},
email: "",
setEmail(value) {
set({ email: value });
},
stepLogin: 1,
setStepLogin(value) {
set({ stepLogin: value });
},
}));
+52
View File
@@ -0,0 +1,52 @@
export type LoginType = {
phone_email: string;
};
export type LoginPasswordType = {
password: string;
};
export type AuthStoreType = {
phone: string;
setPhone: (value: string) => void;
email: string;
setEmail: (value: string) => void;
stepLogin: number;
setStepLogin: (value: number) => void;
};
export type LoginWithPasswordType = {
email?: string;
password: string;
phone?: string;
};
export type LoginWithOtpType = {
phone: string;
};
export type OtpVerifyType = {
phone: string;
code: string;
};
export type CheckHasAccountType = {
email: string;
};
export type CheckHasAccountPhoneType = {
phone: string;
};
export type RegisterType = {
phone: string;
firstName: string;
lastName: string;
birthDate: string;
nationalCode: string;
password: string;
code: number;
};
export type RefreshTokenType = {
refreshToken: string;
};
+16
View File
@@ -0,0 +1,16 @@
import { FC } from 'react'
import { Route, Routes } from 'react-router-dom'
import { Pages } from '../config/Pages'
import Login from '../pages/auth/Login'
import Register from '../pages/auth/Register'
const AuthRouter: FC = () => {
return (
<Routes>
<Route path={Pages.auth.login} element={<Login />} />
<Route path={Pages.auth.register} element={<Register />} />
</Routes>
)
}
export default AuthRouter
+14
View File
@@ -0,0 +1,14 @@
import { DefaultError } from "@tanstack/react-query";
import { IBaseResponse } from "./response.types";
interface IError {
message: string[];
}
export interface IErrorResponse extends IBaseResponse {
error: IError;
}
export interface IApiErrorRepsonse extends DefaultError {
response?: IErrorResponse;
}
+7
View File
@@ -0,0 +1,7 @@
export interface IBaseResponse {
status: number;
success: boolean;
}
export interface IResponse<T> extends IBaseResponse {
data: T;
}
+14
View File
@@ -21,6 +21,20 @@ module.exports = withMT({
}, },
borderRadius: { borderRadius: {
'2.5': '10px' '2.5': '10px'
},
keyframes: {
'slide-in-right': {
'0%': { transform: 'translateX(100%)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' }
},
'slide-out-right': {
'0%': { transform: 'translateX(0)', opacity: '1' },
'100%': { transform: 'translateX(100%)', opacity: '0' }
}
},
animation: {
'slide-in-right': 'slide-in-right 0.3s ease-out',
'slide-out-right': 'slide-out-right 0.3s ease-out'
} }
}, },
}, },