start
This commit is contained in:
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 100
|
||||||
40
.eslintrc.json
Normal file
40
.eslintrc.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"standard",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": 12,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"react",
|
||||||
|
"react-hooks",
|
||||||
|
"@typescript-eslint",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"JSX": "readonly"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
13
.prettierrc.json
Normal file
13
.prettierrc.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `yarn start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `yarn test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `yarn build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `yarn eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
82
package.json
Normal file
82
package.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "introducer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^1.5.0",
|
||||||
|
"@heroicons/react": "^1.0.6",
|
||||||
|
"@persian-tools/persian-tools": "^3.2.1",
|
||||||
|
"@testing-library/dom": "^8.13.0",
|
||||||
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
|
"@testing-library/react": "^13.0.0",
|
||||||
|
"@testing-library/react-hooks": "^7.0.2",
|
||||||
|
"@testing-library/user-event": "^14.0.4",
|
||||||
|
"@types/jest": "^27.0.1",
|
||||||
|
"@types/node": "^16.7.13",
|
||||||
|
"@types/react": "^17.0.20",
|
||||||
|
"@types/react-dom": "^17.0.9",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^0.26.1",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
|
"formik": "^2.2.9",
|
||||||
|
"msw": "^0.39.2",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-hot-toast": "^2.2.0",
|
||||||
|
"react-query": "^3.34.19",
|
||||||
|
"react-router-dom": "^5.3.0",
|
||||||
|
"react-scripts": "5.0.0",
|
||||||
|
"typescript": "^4.4.2",
|
||||||
|
"web-vitals": "^2.1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.17.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.17.0",
|
||||||
|
"@typescript-eslint/parser": "^5.17.0",
|
||||||
|
"autoprefixer": "^10.4.4",
|
||||||
|
"eslint": "^7.12.1",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-config-standard": "^16.0.3",
|
||||||
|
"eslint-plugin-import": "^2.22.1",
|
||||||
|
"eslint-plugin-jest": "^26.1.3",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"eslint-plugin-promise": "^4.2.1",
|
||||||
|
"eslint-plugin-react": "^7.29.4",
|
||||||
|
"eslint-plugin-react-hooks": "^4.4.0",
|
||||||
|
"postcss": "^8.4.12",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.1.8",
|
||||||
|
"react-test-renderer": "^18.0.0",
|
||||||
|
"tailwindcss": "^3.0.23",
|
||||||
|
"tailwindcss-rtl": "^0.9.0"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
78
public/assets/css/fontiran.css
Normal file
78
public/assets/css/fontiran.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
*
|
||||||
|
* Name: IRAN Sans-Serif Font
|
||||||
|
* Version: 5.0
|
||||||
|
* Author: Moslem Ebrahimi (moslemebrahimi.com)
|
||||||
|
* Created on: Dec 25, 2012
|
||||||
|
* Updated on: Sep 01, 2017
|
||||||
|
* Website: http://fontiran.com
|
||||||
|
* Copyright: Commercial/Proprietary Software
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
فونت های ایران سن سریف یک نرم افزار مالکیتی محسوب می شود. جهت آگاهی از قوانین استفاده از این فونت ها لطفا به وب سایت (فونت ایران دات کام) مراجعه نمایید
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
IRAN Sans-serif fonts are considered a proprietary software. To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
This set of fonts are used in this project under the license: (GY3QV)
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
**/
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Black.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Black.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: bold;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Light.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum).eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum).eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum).woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum).woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum).ttf') format('truetype');
|
||||||
|
}
|
||||||
79
public/assets/css/style.css
Normal file
79
public/assets/css/style.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@import url(./fontiran.css);
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: IRANSans !important;
|
||||||
|
font-weight: 300;
|
||||||
|
direction: rtl;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, text, strong, input, textarea {
|
||||||
|
font-family: IRANSans !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div, span, a, label, button {
|
||||||
|
font-family: IRANSans !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloader-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter {
|
||||||
|
height: 10px;
|
||||||
|
width: 172px;
|
||||||
|
background: #f3efe6;
|
||||||
|
direction: ltr;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 5px;
|
||||||
|
animation: progressBar 1.2s ease-in-out infinite;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressBar {
|
||||||
|
0% {
|
||||||
|
width: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
49.99% {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #00B1DA;
|
||||||
|
}
|
||||||
|
50.01% {
|
||||||
|
width: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #FB8928
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-0 {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide arrows from number type input */
|
||||||
|
/* Chrome, Safari, Edge, Opera */
|
||||||
|
input::-webkit-outer-spin-button,
|
||||||
|
input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
1
public/assets/css/style.min.css
vendored
Normal file
1
public/assets/css/style.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import url(./fontiran.css);@keyframes progressBar{0%,50.01%{width:0;background-color:transparent}49.99%{width:100%;background-color:#00b1da}to{width:100%;background-color:#fb8928}}body{font-weight:300;direction:rtl;margin:0}a,body,button,div,h1,h2,h3,h4,h5,h6,input,label,span,strong,text,textarea{font-family:IRANSans!important}.preloader-container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column}.meter{height:10px;width:172px;background:#f3efe6;direction:ltr;border-radius:5px;margin:12px 0}.progress{height:100%;display:block;border-radius:5px;animation:progressBar 1.2s ease-in-out infinite;animation-fill-mode:both}.leading-0{line-height:0}input::-webkit-inner-spin-button,input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}input[type=number]{-moz-appearance:textfield}
|
||||||
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum).eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum).eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum).woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum).woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2
Normal file
Binary file not shown.
12
public/browserconfig.xml
Normal file
12
public/browserconfig.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square70x70logo src="https://medmoshaver.com/icons/ms/icon-70.png"/>
|
||||||
|
<square144x144logo src="https://medmoshaver.com/icons/ms/icon-144.png"/>
|
||||||
|
<square150x150logo src="https://medmoshaver.com/icons/ms/icon-150.png"/>
|
||||||
|
<square310x310logo src="https://medmoshaver.com/icons/ms/icon-310.png"/>
|
||||||
|
<TileColor>#ffffff</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
157
public/index.html
Normal file
157
public/index.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang='fa'>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8' />
|
||||||
|
<meta content='IE=edge' http-equiv='X-UA-Compatible'>
|
||||||
|
<meta
|
||||||
|
content='width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no'
|
||||||
|
name='viewport'
|
||||||
|
>
|
||||||
|
<meta title='پنل همکاری در فروش فلش کارت های گروه مدمشاور'>
|
||||||
|
<meta content='گروه مد مشاور متشکل از تعدادی از رتبههای برتر آزمون دستیاری میباشد
|
||||||
|
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه میدهد.'
|
||||||
|
name='description'
|
||||||
|
>
|
||||||
|
<title>پنل همکاری در فروش فلش کارت های گروه مدمشاور</title>
|
||||||
|
|
||||||
|
<!-- Android -->
|
||||||
|
<meta content='#fafafa' name='theme-color'>
|
||||||
|
<meta content='yes' name='mobile-web-app-capable'>
|
||||||
|
|
||||||
|
<!-- iOS -->
|
||||||
|
<meta content='پنل همکاری در فروش فلش کارت های گروه مدمشاور' name='apple-mobile-web-app-title'>
|
||||||
|
<meta content='yes' name='apple-mobile-web-app-capable'>
|
||||||
|
<meta content='default' name='apple-mobile-web-app-status-bar-style'>
|
||||||
|
|
||||||
|
<!-- Windows -->
|
||||||
|
<meta content='#fafafa' name='msapplication-navbutton-color'>
|
||||||
|
<meta content='#fafafa' name='msapplication-TileColor'>
|
||||||
|
<meta content='https://medmoshaver.com/icons/ms/icon-144.png' name='msapplication-TileImage'>
|
||||||
|
<meta content='%PUBLIC_URL%/browserconfig.xml' name='msapplication-config'>
|
||||||
|
|
||||||
|
<!-- Pinned Sites -->
|
||||||
|
<meta content='Application Name' name='application-name'>
|
||||||
|
<meta content='Tooltip Text' name='msapplication-tooltip'>
|
||||||
|
<meta content='/' name='msapplication-starturl'>
|
||||||
|
|
||||||
|
<!-- Tap highlighting -->
|
||||||
|
<meta content='no' name='msapplication-tap-highlight'>
|
||||||
|
|
||||||
|
<!-- UC Mobile Browser -->
|
||||||
|
<meta content='yes' name='full-screen'>
|
||||||
|
<meta content='application' name='browsermode'>
|
||||||
|
|
||||||
|
<!-- Disable night mode for this page -->
|
||||||
|
<meta content='enable/disable' name='nightmode'>
|
||||||
|
|
||||||
|
<!-- Fitscreen -->
|
||||||
|
<!-- <meta name="viewport" content="uc-fitscreen=yes"/>-->
|
||||||
|
|
||||||
|
<!-- Layout mode -->
|
||||||
|
<meta content='fitscreen/standard' name='layoutmode'>
|
||||||
|
|
||||||
|
<!-- imagemode - show image even in text only mode -->
|
||||||
|
<meta content='force' name='imagemode'>
|
||||||
|
|
||||||
|
<!-- Orientation -->
|
||||||
|
<meta content='portrait' name='screen-orientation'>
|
||||||
|
|
||||||
|
<!-- open graph markup -->
|
||||||
|
<meta content='website' property='og:type' />
|
||||||
|
<meta content='Med Moshaver' property='og:site_name' />
|
||||||
|
<meta content='https://flashcard.medmoshaver.com/bi' property='og:url' />
|
||||||
|
<meta content='مد مشاور - مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی'
|
||||||
|
property='og:title' />
|
||||||
|
<meta content='https://medmoshaver.com/icons/open-graph.png'
|
||||||
|
property='og:image' />
|
||||||
|
<meta content='image/png' property='og:image:type' />
|
||||||
|
<meta content='1200' property='og:image:width' />
|
||||||
|
<meta content='630' property='og:image:height' />
|
||||||
|
<meta
|
||||||
|
content='گروه مد مشاور متشکل از تعدادی از رتبههای برتر آزمون دستیاری میباشد
|
||||||
|
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه میدهد.'
|
||||||
|
property='og:description'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Link Tags -->
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-16.png' rel='icon' sizes='16x16'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-32.png' rel='icon' sizes='32x32'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-48.png' rel='icon' sizes='48x48'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-96.png' rel='icon' sizes='96x96'
|
||||||
|
type='image/png'>
|
||||||
|
|
||||||
|
<!-- iOS -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon.png' rel='apple-touch-icon'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-76.png' rel='apple-touch-icon' sizes='76x76'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-120.png' rel='apple-touch-icon'
|
||||||
|
sizes='120x120'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-152.png' rel='apple-touch-icon'
|
||||||
|
sizes='152x152'>
|
||||||
|
|
||||||
|
<!-- Startup Image -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-320x480.png' rel='apple-touch-startup-image'>
|
||||||
|
|
||||||
|
<!-- Pinned Tab -->
|
||||||
|
<link color='#fafafa' href='https://medmoshaver.com/icons/icon.svg' rel='mask-icon' size='any'>
|
||||||
|
|
||||||
|
<!-- Android -->
|
||||||
|
<link href='https://medmoshaver.com/icons/android/icon-192.png' rel='icon' sizes='192x192'>
|
||||||
|
<link href='https://medmoshaver.com/icons/android/icon-128.png' rel='icon' sizes='128x128'>
|
||||||
|
|
||||||
|
<!-- Others -->
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon.ico' rel='shortcut icon'
|
||||||
|
type='image/x-icon'>
|
||||||
|
|
||||||
|
<!-- UC Browser -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-57.png' rel='apple-touch-icon-precomposed'
|
||||||
|
sizes='57x57'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-72.png' rel='apple-touch-icon' sizes='72x72'>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link href='%PUBLIC_URL%/manifest.json' rel='manifest' />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<link href='%PUBLIC_URL%/assets/css/style.min.css' rel='stylesheet' />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body dir='rtl'>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id='root'>
|
||||||
|
<div class='preloader-container'>
|
||||||
|
<img
|
||||||
|
alt='logo'
|
||||||
|
class='preloader-content'
|
||||||
|
src='https://medmoshaver.com/icons/icon.svg'
|
||||||
|
width='168px'
|
||||||
|
height='168px'
|
||||||
|
/>
|
||||||
|
<div class='meter'>
|
||||||
|
<span class='progress'></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
public/lib/.gitkeep
Normal file
0
public/lib/.gitkeep
Normal file
83
public/manifest.json
Normal file
83
public/manifest.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Medmoshaver FlashCard Introducer Panel",
|
||||||
|
"name": "Medmoshaver FlashCard Introducer Panel",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-36.png",
|
||||||
|
"sizes": "36x36",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "0.75",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "1.0",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "1.5",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "2.0",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "3.0",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "4.0",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"density": "8.0",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://medmoshaver.com/icons/pwa/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#fafafa",
|
||||||
|
"background_color": "#fafafa"
|
||||||
|
}
|
||||||
338
public/mockServiceWorker.js
Normal file
338
public/mockServiceWorker.js
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker (0.39.2).
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
* - Please do NOT serve this file on production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'
|
||||||
|
const bypassHeaderName = 'x-msw-bypass'
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
self.addEventListener('install', function () {
|
||||||
|
return self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', async function (event) {
|
||||||
|
return self.clients.claim()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('message', async function (event) {
|
||||||
|
const clientId = event.source.id
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll()
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: INTEGRITY_CHECKSUM,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: true,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_DEACTIVATE': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve the "main" client for the given event.
|
||||||
|
// Client that issues a request doesn't necessarily equal the client
|
||||||
|
// that registered the worker. It's with the latter the worker should
|
||||||
|
// communicate with during the response resolving phase.
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (client.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll()
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(event, requestId) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const response = await getResponse(event, client, requestId)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
;(async function () {
|
||||||
|
const clonedResponse = response.clone()
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
type: clonedResponse.type,
|
||||||
|
ok: clonedResponse.ok,
|
||||||
|
status: clonedResponse.status,
|
||||||
|
statusText: clonedResponse.statusText,
|
||||||
|
body:
|
||||||
|
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||||
|
headers: serializeHeaders(clonedResponse.headers),
|
||||||
|
redirected: clonedResponse.redirected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResponse(event, client, requestId) {
|
||||||
|
const { request } = event
|
||||||
|
const requestClone = request.clone()
|
||||||
|
const getOriginalResponse = () => fetch(requestClone)
|
||||||
|
|
||||||
|
// Bypass mocking when the request client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return getOriginalResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return await getOriginalResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass requests with the explicit bypass header
|
||||||
|
if (requestClone.headers.get(bypassHeaderName) === 'true') {
|
||||||
|
const cleanRequestHeaders = serializeHeaders(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the bypass header to comply with the CORS preflight check.
|
||||||
|
delete cleanRequestHeaders[bypassHeaderName]
|
||||||
|
|
||||||
|
const originalRequest = new Request(requestClone, {
|
||||||
|
headers: new Headers(cleanRequestHeaders),
|
||||||
|
})
|
||||||
|
|
||||||
|
return fetch(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the request to the client-side MSW.
|
||||||
|
const reqHeaders = serializeHeaders(request.headers)
|
||||||
|
const body = await request.text()
|
||||||
|
|
||||||
|
const clientMessage = await sendToClient(client, {
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
headers: reqHeaders,
|
||||||
|
cache: request.cache,
|
||||||
|
mode: request.mode,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body,
|
||||||
|
bodyUsed: request.bodyUsed,
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_SUCCESS': {
|
||||||
|
return delayPromise(
|
||||||
|
() => respondWithMock(clientMessage),
|
||||||
|
clientMessage.payload.delay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_NOT_FOUND': {
|
||||||
|
return getOriginalResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NETWORK_ERROR': {
|
||||||
|
const { name, message } = clientMessage.payload
|
||||||
|
const networkError = new Error(message)
|
||||||
|
networkError.name = name
|
||||||
|
|
||||||
|
// Rejecting a request Promise emulates a network error.
|
||||||
|
throw networkError
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTERNAL_ERROR': {
|
||||||
|
const parsedBody = JSON.parse(clientMessage.payload.body)
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`\
|
||||||
|
[MSW] Uncaught exception in the request handler for "%s %s":
|
||||||
|
|
||||||
|
${parsedBody.location}
|
||||||
|
|
||||||
|
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
|
||||||
|
`,
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return respondWithMock(clientMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOriginalResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', function (event) {
|
||||||
|
const { request } = event
|
||||||
|
const accept = request.headers.get('accept') || ''
|
||||||
|
|
||||||
|
// Bypass server-sent events.
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been deleted (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = uuidv4()
|
||||||
|
|
||||||
|
return event.respondWith(
|
||||||
|
handleRequest(event, requestId).catch((error) => {
|
||||||
|
if (error.name === 'NetworkError') {
|
||||||
|
console.warn(
|
||||||
|
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, any exception indicates an issue with the original request/response.
|
||||||
|
console.error(
|
||||||
|
`\
|
||||||
|
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
`${error.name}: ${error.message}`,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function serializeHeaders(headers) {
|
||||||
|
const reqHeaders = {}
|
||||||
|
headers.forEach((value, name) => {
|
||||||
|
reqHeaders[name] = reqHeaders[name]
|
||||||
|
? [].concat(reqHeaders[name]).concat(value)
|
||||||
|
: value
|
||||||
|
})
|
||||||
|
return reqHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToClient(client, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(JSON.stringify(message), [channel.port2])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delayPromise(cb, duration) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(cb()), duration)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondWithMock(clientMessage) {
|
||||||
|
return new Response(clientMessage.payload.body, {
|
||||||
|
...clientMessage.payload,
|
||||||
|
headers: clientMessage.payload.headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv4() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
const v = c == 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
BIN
public/voices/introduction.mp3
Normal file
BIN
public/voices/introduction.mp3
Normal file
Binary file not shown.
52
src/App.test.tsx
Normal file
52
src/App.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { renderWithClient } from './utils/test/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import { createRouterWrapper } from './utils/test/react-router-dom';
|
||||||
|
import {
|
||||||
|
expectPageTestIds,
|
||||||
|
expectPageTitle,
|
||||||
|
expectRedirectedToHomePage,
|
||||||
|
expectRedirectedToLoginPage
|
||||||
|
} from './utils/test/expects';
|
||||||
|
import { ROOT_PAGE_TITLE } from './components/root';
|
||||||
|
import { REGISTER_PAGE_TITLE } from './components/register';
|
||||||
|
import { TERMS_AND_CONDITIONS_PAGE_TITLE } from './components/termsAndConditions';
|
||||||
|
import { LOGIN_PAGE_TITLE } from './components/login';
|
||||||
|
import { mockFailureResponse } from './mocks/server';
|
||||||
|
import { HOME_PAGE_TITLE } from './components/home';
|
||||||
|
import { FINANCIAL_PAGE_TITLE } from './components/financial';
|
||||||
|
|
||||||
|
describe('App component', () => {
|
||||||
|
const routes = [
|
||||||
|
{ name: 'root', route: '/', title: ROOT_PAGE_TITLE },
|
||||||
|
{ name: 'home', route: '/home', title: HOME_PAGE_TITLE },
|
||||||
|
{ name: 'financial', route: '/financial', title: FINANCIAL_PAGE_TITLE },
|
||||||
|
{ name: 'login', route: '/login', title: LOGIN_PAGE_TITLE },
|
||||||
|
{ name: 'register', route: '/register', title: REGISTER_PAGE_TITLE },
|
||||||
|
{
|
||||||
|
name: 'terms-and-conditions',
|
||||||
|
route: '/terms-and-conditions',
|
||||||
|
title: TERMS_AND_CONDITIONS_PAGE_TITLE
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < routes.length; i++) {
|
||||||
|
const { name, route, title } = routes[i];
|
||||||
|
|
||||||
|
test(`successful render ${name} page`, async () => {
|
||||||
|
const result = renderWithClient(<App />, createRouterWrapper(route));
|
||||||
|
await expectPageTitle(title);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('successful redirect loginRequired page to login page when user is not authenticated', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<App />, createRouterWrapper('/home'));
|
||||||
|
await expectRedirectedToLoginPage(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful redirect register page to root page when user is authenticated', async () => {
|
||||||
|
const result = renderWithClient(<App />, createRouterWrapper('/register'));
|
||||||
|
await expectRedirectedToHomePage(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/App.tsx
Normal file
24
src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
import RootPage from './components/root';
|
||||||
|
import HomePage from './components/home';
|
||||||
|
import LoginPage from './components/login';
|
||||||
|
import DefaultPage from './components/default';
|
||||||
|
import RegisterPage from './components/register';
|
||||||
|
import TermsAndConditionsPage from './components/termsAndConditions';
|
||||||
|
import FinancialPage from './components/financial';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route exact path='/' component={RootPage} />
|
||||||
|
<Route exact path='/home' component={HomePage} />
|
||||||
|
<Route exact path='/financial' component={FinancialPage} />
|
||||||
|
<Route exact path='/login' component={LoginPage} />
|
||||||
|
<Route exact path='/register' component={RegisterPage} />
|
||||||
|
<Route exact path='/terms-and-conditions' component={TermsAndConditionsPage} />
|
||||||
|
<Route path='*' component={DefaultPage} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
61
src/api/__tests__/auth-api.test.ts
Normal file
61
src/api/__tests__/auth-api.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { mockedAccessToken, mockedLoginData, mockedRefreshToken } from '../../mocks/data';
|
||||||
|
import { fetchFreshToken, fetchToken, getFreshToken, getToken } from '../auth';
|
||||||
|
import { APIErrorType } from '../index';
|
||||||
|
|
||||||
|
describe('getToken function', () => {
|
||||||
|
test('successful get token', async () => {
|
||||||
|
const response = await getToken();
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toBe(mockedAccessToken);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFreshToken function', () => {
|
||||||
|
test('successful get fresh token', async () => {
|
||||||
|
const response = await getFreshToken();
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toBe(mockedAccessToken);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetch token api', () => {
|
||||||
|
test('successful fetch token', async () => {
|
||||||
|
const response = await fetchToken(mockedLoginData);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toMatchObject({ access: mockedAccessToken });
|
||||||
|
expect(response).toMatchObject({ refresh: mockedRefreshToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure fetch token', async () => {
|
||||||
|
const { mobile, password } = mockedLoginData;
|
||||||
|
try {
|
||||||
|
await fetchToken({ mobile, password: password.substring(1) });
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetch fresh token api', () => {
|
||||||
|
test('successful fetch fresh token', async () => {
|
||||||
|
const response = await fetchFreshToken(mockedRefreshToken);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toMatchObject({ access: mockedAccessToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure fetch fresh token', async () => {
|
||||||
|
try {
|
||||||
|
await fetchFreshToken(mockedRefreshToken.substring(1));
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
70
src/api/__tests__/introducer-api.test.ts
Normal file
70
src/api/__tests__/introducer-api.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { createIntroducer, fetchIntroducer } from '../introducer';
|
||||||
|
import { mockFailureResponse } from '../../mocks/server';
|
||||||
|
import { APIErrorType } from '../index';
|
||||||
|
import { mockedCreateIntroducerData } from '../../mocks/data';
|
||||||
|
|
||||||
|
const introducerProperties = ['id', 'user', 'code', 'iban', 'sharePrice', 'studentCount'];
|
||||||
|
const codeProperties = [
|
||||||
|
'id',
|
||||||
|
'text',
|
||||||
|
'share',
|
||||||
|
'discount',
|
||||||
|
'validDays',
|
||||||
|
'sharePrice',
|
||||||
|
'studentCount'
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('fetch introducer api', () => {
|
||||||
|
test('successful fetching introducer', async () => {
|
||||||
|
const response = await fetchIntroducer();
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
for (const introducerProperty of introducerProperties) {
|
||||||
|
expect(response).toHaveProperty(introducerProperty);
|
||||||
|
}
|
||||||
|
for (const codeProperty of codeProperties) {
|
||||||
|
expect(response.code).toHaveProperty(codeProperty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure fetching introducer', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await fetchIntroducer();
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create introducer api', () => {
|
||||||
|
const data = mockedCreateIntroducerData;
|
||||||
|
|
||||||
|
test('successful creating introducer', async () => {
|
||||||
|
const response = await createIntroducer(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
for (const introducerProperty of introducerProperties) {
|
||||||
|
expect(response).toHaveProperty(introducerProperty);
|
||||||
|
}
|
||||||
|
expect(response).toMatchObject({ iban: data.iban });
|
||||||
|
for (const codeProperty of codeProperties) {
|
||||||
|
expect(response.code).toHaveProperty(codeProperty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure creating introducer', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await createIntroducer(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/api/__tests__/settle-requests-api.test.ts
Normal file
62
src/api/__tests__/settle-requests-api.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { mockFailureResponse } from '../../mocks/server';
|
||||||
|
import { APIErrorType } from '../index';
|
||||||
|
import { createSettleRequest, fetchSettleRequests } from '../settle-request';
|
||||||
|
import { mockedIBan } from '../../mocks/data';
|
||||||
|
|
||||||
|
const settleRequestProperties = ['id', 'status', 'iban', 'transaction', 'created'];
|
||||||
|
const transactionProperties = ['id', 'amount', 'date', 'paidFor'];
|
||||||
|
|
||||||
|
describe('fetch settle requests api', () => {
|
||||||
|
test('successful fetching settle requests', async () => {
|
||||||
|
const response = await fetchSettleRequests();
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
for (const responseElemet of response) {
|
||||||
|
for (const settleRequestProperty of settleRequestProperties) {
|
||||||
|
expect(responseElemet).toHaveProperty(settleRequestProperty);
|
||||||
|
}
|
||||||
|
if (responseElemet.transaction) {
|
||||||
|
for (const transactionProperty of transactionProperties) {
|
||||||
|
expect(responseElemet.transaction).toHaveProperty(transactionProperty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure fetching list settle requests', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await fetchSettleRequests();
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create settle requests api', () => {
|
||||||
|
const data = { iban: mockedIBan };
|
||||||
|
|
||||||
|
test('successful creating user', async () => {
|
||||||
|
const response = await createSettleRequest(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toHaveProperty('id');
|
||||||
|
expect(response).toMatchObject({ iban: data.iban });
|
||||||
|
expect(response).toMatchObject({ status: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure creating user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await createSettleRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/api/__tests__/temp-user-api.test.ts
Normal file
121
src/api/__tests__/temp-user-api.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { checkSmsKeyRequest, createTempUser, sendSmsKeyRequest } from '../temp-user';
|
||||||
|
import { mockFailureResponse } from '../../mocks/server';
|
||||||
|
import { APIErrorType } from '../index';
|
||||||
|
import {
|
||||||
|
mockedMobile,
|
||||||
|
mockedRestrictedMobile,
|
||||||
|
mockedSmsKey,
|
||||||
|
mockedTempUserData,
|
||||||
|
mockedWrongSmsKey
|
||||||
|
} from '../../mocks/data';
|
||||||
|
|
||||||
|
describe('create tempUser api', () => {
|
||||||
|
const data = mockedTempUserData;
|
||||||
|
|
||||||
|
test('successful creating tempUser', async () => {
|
||||||
|
const response = await createTempUser(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toMatchObject({ mobile: data.mobile });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure creating tempUser', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await createTempUser(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tempUser send smsKey api', () => {
|
||||||
|
const data = mockedTempUserData;
|
||||||
|
|
||||||
|
test('successful send smsKey', async () => {
|
||||||
|
const response = await sendSmsKeyRequest(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toMatchObject({ mobile: data.mobile });
|
||||||
|
expect(response).not.toHaveProperty('authKey');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure send smsKey', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await sendSmsKeyRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure send smsKey with error 403', async () => {
|
||||||
|
const data = { mobile: mockedRestrictedMobile };
|
||||||
|
try {
|
||||||
|
await sendSmsKeyRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
expect(error.data).toBe('محدودیت ارسال کد پیامکی برای شما به پایان رسیده است.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tempUser check smsKey api', () => {
|
||||||
|
test('successful check smsKey', async () => {
|
||||||
|
const data = { mobile: mockedMobile, smsKey: mockedSmsKey };
|
||||||
|
const response = await checkSmsKeyRequest(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toMatchObject({ mobile: data.mobile });
|
||||||
|
expect(response).toHaveProperty('authKey');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure check smsKey', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
|
||||||
|
const data = { mobile: mockedMobile, smsKey: mockedSmsKey };
|
||||||
|
try {
|
||||||
|
await checkSmsKeyRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure check smsKey with error 403', async () => {
|
||||||
|
const data = { mobile: mockedRestrictedMobile, smsKey: mockedSmsKey };
|
||||||
|
try {
|
||||||
|
await checkSmsKeyRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
expect(error.data).toBe('محدودیت وارد کردن کد پیامکی اشتباه برای شما به پایان رسیده است.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure check smsKey with other errors', async () => {
|
||||||
|
const data = { mobile: mockedMobile, smsKey: mockedWrongSmsKey };
|
||||||
|
try {
|
||||||
|
await checkSmsKeyRequest(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
expect(error.data.smsKey).toBe('کد وارد شده اشتباه است.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/api/__tests__/user-api.test.ts
Normal file
52
src/api/__tests__/user-api.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { createUser, fetchUser } from '../user';
|
||||||
|
import { mockFailureResponse } from '../../mocks/server';
|
||||||
|
import { APIErrorType } from '../index';
|
||||||
|
import { mockedCreateUserData } from '../../mocks/data';
|
||||||
|
|
||||||
|
describe('fetchUser api', () => {
|
||||||
|
test('successful fetching user', async () => {
|
||||||
|
const response = await fetchUser();
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toHaveProperty('id');
|
||||||
|
expect(response).toHaveProperty('name');
|
||||||
|
expect(response).toHaveProperty('mobile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure fetching user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await fetchUser();
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create user api', () => {
|
||||||
|
const data = mockedCreateUserData;
|
||||||
|
|
||||||
|
test('successful creating user', async () => {
|
||||||
|
const response = await createUser(data);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response).toHaveProperty('id');
|
||||||
|
expect(response).toMatchObject({ name: `${data.firstName} ${data.lastName}` });
|
||||||
|
expect(response).toMatchObject({ mobile: data.mobile });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure creating user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
try {
|
||||||
|
await createUser(data);
|
||||||
|
// Fail test if above expression doesn't throw anything.
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as APIErrorType;
|
||||||
|
expect(error.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/api/auth.ts
Normal file
50
src/api/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { request } from './index';
|
||||||
|
|
||||||
|
// local storage keys
|
||||||
|
export const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
|
export const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
|
||||||
|
export type TokenType = string;
|
||||||
|
|
||||||
|
export async function getToken(): Promise<TokenType> {
|
||||||
|
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
if (accessToken) return accessToken;
|
||||||
|
return await getFreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreshToken(): Promise<TokenType> {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
if (!refreshToken) throw Error('Refresh token not found.');
|
||||||
|
const data = await fetchFreshToken(refreshToken);
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, data.access);
|
||||||
|
return data.access;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchTokenDataType = { mobile: string; password: string };
|
||||||
|
export type FetchTokenReturnType = { access: TokenType; refresh: TokenType };
|
||||||
|
|
||||||
|
export async function fetchToken({
|
||||||
|
mobile,
|
||||||
|
password
|
||||||
|
}: FetchTokenDataType): Promise<FetchTokenReturnType> {
|
||||||
|
return await request(false, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/login',
|
||||||
|
data: { mobile, password }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchFreshTokenDataType = { refresh: TokenType };
|
||||||
|
export type FetchFreshTokenReturnType = { access: TokenType };
|
||||||
|
|
||||||
|
export async function fetchFreshToken(token: TokenType): Promise<FetchFreshTokenReturnType> {
|
||||||
|
return await request(false, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/refresh',
|
||||||
|
data: { refresh: token }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutUser(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
32
src/api/index.ts
Normal file
32
src/api/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { getFreshToken, getToken } from './auth';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: process.env.REACT_APP_API_BASE_URL,
|
||||||
|
headers: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type APIErrorType = AxiosResponse;
|
||||||
|
|
||||||
|
export async function request(auth = false, options: AxiosRequestConfig) {
|
||||||
|
if (auth) {
|
||||||
|
const accessToken = await getToken();
|
||||||
|
options.headers = { ...options.headers, Authorization: `Bearer ${accessToken}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await apiClient(options)).data;
|
||||||
|
} catch (error) {
|
||||||
|
if (!axios.isAxiosError(error)) throw error;
|
||||||
|
if (!auth || error.response?.status !== 401) throw error.response;
|
||||||
|
const accessToken = await getFreshToken();
|
||||||
|
options.headers = { ...options.headers, Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await apiClient(options)).data;
|
||||||
|
} catch (error) {
|
||||||
|
if (!axios.isAxiosError(error)) throw error;
|
||||||
|
throw error.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/api/introducer.ts
Normal file
46
src/api/introducer.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { request } from './index';
|
||||||
|
import { UserType } from './user';
|
||||||
|
|
||||||
|
export type CodeType = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
share: number;
|
||||||
|
discount: number;
|
||||||
|
validDays: number;
|
||||||
|
sharePrice: number;
|
||||||
|
studentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntroducerType = {
|
||||||
|
id: number;
|
||||||
|
user: UserType;
|
||||||
|
iban: string;
|
||||||
|
sharePrice: number;
|
||||||
|
paidPrice: number;
|
||||||
|
studentCount: number;
|
||||||
|
code: CodeType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchIntroducer(): Promise<IntroducerType> {
|
||||||
|
return await request(true, { url: '/introducers/introducer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateIntroducerDataType = { iban: string };
|
||||||
|
|
||||||
|
export async function createIntroducer(data: CreateIntroducerDataType): Promise<IntroducerType> {
|
||||||
|
return await request(true, {
|
||||||
|
url: '/introducers/introducers',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateIntroducerDataType = { iban: string };
|
||||||
|
|
||||||
|
export async function updateIntroducer(data: UpdateIntroducerDataType): Promise<IntroducerType> {
|
||||||
|
return await request(true, {
|
||||||
|
url: '/introducers/introducer',
|
||||||
|
method: 'PATCH',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
32
src/api/settle-request.ts
Normal file
32
src/api/settle-request.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { request } from './index';
|
||||||
|
|
||||||
|
export type TransactionType = {
|
||||||
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
date: string;
|
||||||
|
paidFor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettleRequestType = {
|
||||||
|
id: number;
|
||||||
|
status: boolean;
|
||||||
|
iban: string;
|
||||||
|
transaction: TransactionType | null;
|
||||||
|
created: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchSettleRequests(): Promise<SettleRequestType[]> {
|
||||||
|
return await request(true, { url: '/introducers/settle-requests' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateSettleRequestDataType = { iban: string };
|
||||||
|
|
||||||
|
export async function createSettleRequest(
|
||||||
|
data: CreateSettleRequestDataType
|
||||||
|
): Promise<SettleRequestType> {
|
||||||
|
return await request(true, {
|
||||||
|
url: '/introducers/settle-requests',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
38
src/api/temp-user.ts
Normal file
38
src/api/temp-user.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { request } from './index';
|
||||||
|
|
||||||
|
export type CreateTempUserDataType = { mobile: string };
|
||||||
|
export type TempUserType = CreateTempUserDataType;
|
||||||
|
|
||||||
|
export async function createTempUser(data: CreateTempUserDataType): Promise<TempUserType> {
|
||||||
|
return await request(false, {
|
||||||
|
url: '/users/temp-users',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendSmsKeyRequestDataType = { mobile: string };
|
||||||
|
export type SendSmsKeyRequestReturnType = SendSmsKeyRequestDataType;
|
||||||
|
|
||||||
|
export async function sendSmsKeyRequest(
|
||||||
|
data: SendSmsKeyRequestDataType
|
||||||
|
): Promise<SendSmsKeyRequestReturnType> {
|
||||||
|
return await request(false, {
|
||||||
|
url: '/users/temp-users/send-sms-key',
|
||||||
|
method: 'PATCH',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckSmsKeyRequestDataType = { mobile: string; smsKey: string };
|
||||||
|
export type CheckSmsKeyRequestReturnType = { mobile: string; authKey: string };
|
||||||
|
|
||||||
|
export async function checkSmsKeyRequest(
|
||||||
|
data: CheckSmsKeyRequestDataType
|
||||||
|
): Promise<CheckSmsKeyRequestReturnType> {
|
||||||
|
return await request(false, {
|
||||||
|
url: '/users/temp-users/check-sms-key',
|
||||||
|
method: 'PATCH',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
34
src/api/user.ts
Normal file
34
src/api/user.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { request } from './index';
|
||||||
|
|
||||||
|
export type UserType = {
|
||||||
|
id: number;
|
||||||
|
mobile: string;
|
||||||
|
name: string;
|
||||||
|
isSuperUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchUser(): Promise<UserType> {
|
||||||
|
return await request(true, { url: '/users/user' });
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserBaseType = {
|
||||||
|
mobile: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
gender: '' | 'M' | 'F';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserDataType = CreateUserBaseType & { password: string; authKey: string };
|
||||||
|
export type CreateUserReturnType = CreateUserBaseType & {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createUser(data: CreateUserDataType): Promise<CreateUserReturnType> {
|
||||||
|
return await request(false, {
|
||||||
|
url: '/users/users',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/components/default/__tests__/default-page.test.tsx
Normal file
18
src/components/default/__tests__/default-page.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import DefaultPage from '../index';
|
||||||
|
import { expectPageTestIds } from '../../../utils/test/expects';
|
||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
|
||||||
|
describe('DefaultPage component', () => {
|
||||||
|
test('successful render page', async () => {
|
||||||
|
const result = renderWithClient(<DefaultPage />, MemoryRouter);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not redirect to login with not authenticated user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
renderWithClient(<DefaultPage />, MemoryRouter);
|
||||||
|
expect(window.location.pathname).not.toBe('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
7
src/components/default/index.tsx
Normal file
7
src/components/default/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
|
||||||
|
function DefaultPage() {
|
||||||
|
return <MainLayout loginRequired={false} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DefaultPage;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
import FinancialContainer from '../container';
|
||||||
|
|
||||||
|
describe('SettleRequestsContainer component', () => {
|
||||||
|
test('successful render loader component', async () => {
|
||||||
|
const result = renderWithClient(<FinancialContainer />);
|
||||||
|
expect(await result.findByTestId('loader')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render data', async () => {
|
||||||
|
const result = renderWithClient(<FinancialContainer />);
|
||||||
|
expect(await result.findByTestId('settle-requests')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render error data', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<FinancialContainer />);
|
||||||
|
expect(await result.findByTestId('error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/components/financial/__tests__/financial-page.test.tsx
Normal file
25
src/components/financial/__tests__/financial-page.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
import {
|
||||||
|
expectPageTestIds,
|
||||||
|
expectPageTitle,
|
||||||
|
expectRedirectedToLoginPage
|
||||||
|
} from '../../../utils/test/expects';
|
||||||
|
import FinancialPage, { FINANCIAL_PAGE_TITLE } from '../index';
|
||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { createRouterWrapper } from '../../../utils/test/react-router-dom';
|
||||||
|
import App from '../../../App';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
describe('FinancialPage component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<FinancialPage />, MemoryRouter);
|
||||||
|
await expectPageTitle(FINANCIAL_PAGE_TITLE);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirect to login with not authenticated user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<App />, createRouterWrapper('/home'));
|
||||||
|
await expectRedirectedToLoginPage(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/components/financial/__tests__/financial-tab.test.tsx
Normal file
13
src/components/financial/__tests__/financial-tab.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import FinancialTab, { TAB_NAMES } from '../financial-tab';
|
||||||
|
import { mockedSettleRequests } from '../../../mocks/data';
|
||||||
|
|
||||||
|
describe('FinancialTab component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<FinancialTab settleRequests={mockedSettleRequests} />);
|
||||||
|
expect(await result.findByTestId('financial-tab')).toBeInTheDocument();
|
||||||
|
for (const tabName of TAB_NAMES) {
|
||||||
|
expect(await result.findByText(tabName)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/components/financial/container.tsx
Normal file
15
src/components/financial/container.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Loader from '../shared/loader';
|
||||||
|
import { useSettleRequests } from '../../hooks/react-query/use-settle-request';
|
||||||
|
import Error from '../shared/error';
|
||||||
|
import { APIErrorType } from '../../api';
|
||||||
|
import FinancialTab from './financial-tab';
|
||||||
|
|
||||||
|
function FinancialContainer() {
|
||||||
|
const { isLoading, isError, error, isSuccess, data } = useSettleRequests();
|
||||||
|
if (isLoading) return <Loader />;
|
||||||
|
if (isError) return <Error error={error as APIErrorType} />;
|
||||||
|
if (isSuccess) return <FinancialTab settleRequests={data} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FinancialContainer;
|
||||||
47
src/components/financial/financial-tab.tsx
Normal file
47
src/components/financial/financial-tab.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Tab } from '@headlessui/react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { SettleRequestType } from '../../api/settle-request';
|
||||||
|
import SettleRequests from './settle-requests/component';
|
||||||
|
import SettleRequest from './settle-request';
|
||||||
|
|
||||||
|
export type FinancialTabPropsType = { settleRequests: SettleRequestType[] };
|
||||||
|
|
||||||
|
function FinancialTab({ settleRequests }: FinancialTabPropsType) {
|
||||||
|
const hasPendingSettleRequest =
|
||||||
|
settleRequests.length > 0 && !settleRequests[settleRequests.length - 1].status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid='financial-tab'>
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className='mx-auto mt-6 mb-10 flex w-fit rounded bg-gray-400'>
|
||||||
|
{TAB_NAMES.map(tabName => (
|
||||||
|
<Tab
|
||||||
|
key={tabName}
|
||||||
|
className={({ selected }) =>
|
||||||
|
classNames(
|
||||||
|
'py-1.5 px-3 text-sm text-white hover:font-medium',
|
||||||
|
'first:rounded-ts first:rounded-bs last:rounded-te last:rounded-be',
|
||||||
|
selected ? 'bg-teal-600' : 'hover:bg-gray-500'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tabName}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<Tab.Panel>
|
||||||
|
<SettleRequests settleRequests={settleRequests} />
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel>
|
||||||
|
<SettleRequest hasPendingSettleRequest={hasPendingSettleRequest} />
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAB_NAMES = ['سوابق مالی', 'درخواست تسویه جدید'];
|
||||||
|
|
||||||
|
export default FinancialTab;
|
||||||
14
src/components/financial/index.tsx
Normal file
14
src/components/financial/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import FinancialContainer from './container';
|
||||||
|
|
||||||
|
function FinancialPage() {
|
||||||
|
return (
|
||||||
|
<MainLayout introducerRequired title={FINANCIAL_PAGE_TITLE}>
|
||||||
|
<FinancialContainer />
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FINANCIAL_PAGE_TITLE = 'سوابق مالی';
|
||||||
|
|
||||||
|
export default FinancialPage;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import CreateSettleRequestForm from '../create-settle-request-form';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { mockedIBan } from '../../../../mocks/data';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import * as settleRequestAPI from '../../../../api/settle-request';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
describe('CreateSettleRequestForm component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<CreateSettleRequestForm />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call create introducer function with correct arguments', async () => {
|
||||||
|
const createIntroducerSpy = jest.spyOn(settleRequestAPI, 'createSettleRequest');
|
||||||
|
const data = { iban: mockedIBan };
|
||||||
|
|
||||||
|
const result = renderWithClient(<CreateSettleRequestForm />, MemoryRouter);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(result.getByLabelText(/شبا/), data.iban);
|
||||||
|
await user.click(await result.getByRole('button', { name: /درخواست تسویه حساب/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createIntroducerSpy).toHaveBeenCalledWith(data));
|
||||||
|
createIntroducerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import CreateSettleRequest from '../component';
|
||||||
|
|
||||||
|
describe('CreateSettleRequest component', () => {
|
||||||
|
test('successful render form', async () => {
|
||||||
|
const result = renderWithClient(<CreateSettleRequest iban={''} />);
|
||||||
|
expect(await result.findByTestId('create-settle-request')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import CreateSettleRequestContainer from '../container';
|
||||||
|
|
||||||
|
describe('SettleRequestContainer component', () => {
|
||||||
|
test('successful render data', async () => {
|
||||||
|
const result = renderWithClient(<CreateSettleRequestContainer />);
|
||||||
|
expect(await result.findByTestId('create-settle-request')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import SettleRequest from '../index';
|
||||||
|
|
||||||
|
describe('SettleRequest component', () => {
|
||||||
|
test('successful render create settle request form', async () => {
|
||||||
|
const result = renderWithClient(<SettleRequest hasPendingSettleRequest={false} />);
|
||||||
|
expect(await result.findByTestId('settle-request')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('create-settle-request')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render message if pending settle request exists', async () => {
|
||||||
|
const result = renderWithClient(<SettleRequest hasPendingSettleRequest />);
|
||||||
|
expect(await result.findByTestId('settle-request')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('pending-settle-request-exists')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/components/financial/settle-request/component.tsx
Normal file
16
src/components/financial/settle-request/component.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import CreateSettleRequestForm from './create-settle-request-form';
|
||||||
|
|
||||||
|
export type CreateSettleRequestPropsType = { iban: string };
|
||||||
|
|
||||||
|
function CreateSettleRequest({ iban }: CreateSettleRequestPropsType) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid='create-settle-request'
|
||||||
|
className='mx-auto w-full w-full sm:w-4/5 md:w-3/4 lg:w-2/3'
|
||||||
|
>
|
||||||
|
<CreateSettleRequestForm initialIban={iban} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateSettleRequest;
|
||||||
10
src/components/financial/settle-request/container.tsx
Normal file
10
src/components/financial/settle-request/container.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useIntroducer } from '../../../hooks/react-query/use-introducer';
|
||||||
|
import CreateSettleRequest from './component';
|
||||||
|
|
||||||
|
function CreateSettleRequestContainer() {
|
||||||
|
const { isSuccess, data } = useIntroducer();
|
||||||
|
if (isSuccess) return <CreateSettleRequest iban={data.iban} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateSettleRequestContainer;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import Segment from '../../shared/segment';
|
||||||
|
import { requiredIban } from '../../shared/forms/validations';
|
||||||
|
import Button from '../../shared/buttons/button';
|
||||||
|
import Alert from '../../shared/alert';
|
||||||
|
import { useFormErrorMessage } from '../../../hooks/index/use-form-error-message';
|
||||||
|
import FormInput from '../../shared/forms/input';
|
||||||
|
import { useCreateSettleRequestMutation } from '../../../hooks/react-query/use-settle-request';
|
||||||
|
import { CreateSettleRequestDataType } from '../../../api/settle-request';
|
||||||
|
|
||||||
|
export type CreateSettleRequestFormPropsType = { initialIban?: string };
|
||||||
|
|
||||||
|
function CreateSettleRequestForm({ initialIban = '' }: CreateSettleRequestFormPropsType) {
|
||||||
|
const { formErrorMessage, setFormError, resetFormErrorMessage } = useFormErrorMessage();
|
||||||
|
const { mutate: createSettleRequest } = useCreateSettleRequestMutation();
|
||||||
|
|
||||||
|
function onSuccess() {
|
||||||
|
toast.success('درخواست تسویه شما با موفقیت ثبت شد.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(data: CreateSettleRequestDataType) {
|
||||||
|
resetFormErrorMessage();
|
||||||
|
await createSettleRequest(data, {
|
||||||
|
onSuccess,
|
||||||
|
onError: setFormError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment dataTestId='create-settle-request-form' className='rounded-lg p-8'>
|
||||||
|
<Formik initialValues={{ iban: initialIban }} onSubmit={submit}>
|
||||||
|
{() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{formErrorMessage && (
|
||||||
|
<Alert className='mt-3 mb-5'>
|
||||||
|
<p>{formErrorMessage}</p>
|
||||||
|
<p className='mt-1 text-sm font-normal'>
|
||||||
|
<span>برای اطلاعات بیشتر به بخش </span>
|
||||||
|
<Link to='/terms-and-conditions' target='_blank' className='text-blue-600'>
|
||||||
|
شرایط و قوانین
|
||||||
|
</Link>
|
||||||
|
<span> مراجعه نمایید.</span>
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form noValidate data-testid='login-form' className='grid grid-cols-1 gap-5 py-3'>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='iban'
|
||||||
|
label='شماره شبا'
|
||||||
|
prefix='IR-'
|
||||||
|
placeholder='123456789012345678901234'
|
||||||
|
component={FormInput}
|
||||||
|
type='tel'
|
||||||
|
maxLength={24}
|
||||||
|
validate={requiredIban}
|
||||||
|
/>
|
||||||
|
<Button type='submit' color='green' content='درخواست تسویه حساب' />
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateSettleRequestForm;
|
||||||
22
src/components/financial/settle-request/index.tsx
Normal file
22
src/components/financial/settle-request/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import CreateSettleRequestContainer from './container';
|
||||||
|
import Segment from '../../shared/segment';
|
||||||
|
|
||||||
|
export type SettlePropsType = { hasPendingSettleRequest: boolean };
|
||||||
|
|
||||||
|
function SettleRequest({ hasPendingSettleRequest }: SettlePropsType) {
|
||||||
|
return (
|
||||||
|
<div data-testid='settle-request'>
|
||||||
|
{hasPendingSettleRequest ? (
|
||||||
|
<Segment dataTestId='pending-settle-request-exists' textAlign='center' className='text-lg'>
|
||||||
|
<p>درخواست تسویه شما ثبت شده و درحال بررسی است.</p>
|
||||||
|
<p>این درخواست نهایتا تا 48 ساعت بررسی میشود، لطفا تا بررسی نتیجه منتظر بمانید.</p>
|
||||||
|
<p>باتشکر</p>
|
||||||
|
</Segment>
|
||||||
|
) : (
|
||||||
|
<CreateSettleRequestContainer />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettleRequest;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import SettleRequests from '../component';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { mockedSettleRequests } from '../../../../mocks/data';
|
||||||
|
|
||||||
|
describe('IntroducerTransactionsContent component', () => {
|
||||||
|
test('successful render empty data', async () => {
|
||||||
|
const result = render(<SettleRequests settleRequests={[]} />);
|
||||||
|
expect(await result.findByTestId('empty-settle-requests')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render non empty data', async () => {
|
||||||
|
const result = render(<SettleRequests settleRequests={mockedSettleRequests} />);
|
||||||
|
expect(await result.findByTestId('settle-requests-table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/components/financial/settle-requests/component.tsx
Normal file
69
src/components/financial/settle-requests/component.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Segment from '../../shared/segment';
|
||||||
|
import { BLANK_TEXT } from '../../utils';
|
||||||
|
import { SettleRequestType } from '../../../api/settle-request';
|
||||||
|
|
||||||
|
export type SettleRequestsPropsType = { settleRequests: SettleRequestType[] };
|
||||||
|
|
||||||
|
function SettleRequests({ settleRequests }: SettleRequestsPropsType) {
|
||||||
|
return (
|
||||||
|
<div data-testid='settle-requests' className='mx-auto w-full sm:w-4/5 md:w-3/4 lg:w-2/3'>
|
||||||
|
{settleRequests.length === 0 ? (
|
||||||
|
<Segment dataTestId='empty-settle-requests' textAlign='center' className='text-lg'>
|
||||||
|
سابقه درخواست تسویه برای شما یافت نشد.
|
||||||
|
</Segment>
|
||||||
|
) : (
|
||||||
|
<table
|
||||||
|
data-testid='settle-requests-table'
|
||||||
|
className='my-8 mx-1 w-full table-auto border text-center text-sm text-slate-500'
|
||||||
|
>
|
||||||
|
<thead className='bg-slate-200 text-xs text-slate-700'>
|
||||||
|
<tr>
|
||||||
|
<th scope='col' className='px-3 py-3 sm:px-6'>
|
||||||
|
شناسه
|
||||||
|
</th>
|
||||||
|
<th scope='col' className='px-3 py-3 sm:px-6'>
|
||||||
|
وضعیت
|
||||||
|
</th>
|
||||||
|
<th scope='col' className='px-3 py-3 sm:px-6'>
|
||||||
|
تاریخ درخواست
|
||||||
|
</th>
|
||||||
|
<th scope='col' className='px-3 py-3 sm:px-6'>
|
||||||
|
مبلغ
|
||||||
|
</th>
|
||||||
|
<th scope='col' className='px-3 py-3 sm:px-6'>
|
||||||
|
تاریخ پرداخت
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{settleRequests.map(settleRequest => (
|
||||||
|
<tr key={settleRequest.id} className='border-b odd:bg-white even:bg-slate-100'>
|
||||||
|
<td scope='row' className='px-3 py-4 sm:px-6'>
|
||||||
|
{settleRequest.id}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 sm:px-6'>
|
||||||
|
{settleRequest.status ? 'پرداخت شده' : 'درحال بررسی'}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 sm:px-6'>
|
||||||
|
{new Date(settleRequest.created).toLocaleDateString('fa')}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 sm:px-6'>
|
||||||
|
{settleRequest.transaction
|
||||||
|
? `${settleRequest.transaction?.amount.toLocaleString()} ریال`
|
||||||
|
: BLANK_TEXT}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 sm:px-6'>
|
||||||
|
{settleRequest.transaction
|
||||||
|
? new Date(settleRequest.transaction?.date).toLocaleDateString('fa')
|
||||||
|
: BLANK_TEXT}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettleRequests;
|
||||||
25
src/components/home/__tests__/home-page.test.tsx
Normal file
25
src/components/home/__tests__/home-page.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
import {
|
||||||
|
expectPageTestIds,
|
||||||
|
expectPageTitle,
|
||||||
|
expectRedirectedToLoginPage
|
||||||
|
} from '../../../utils/test/expects';
|
||||||
|
import HomePage, { HOME_PAGE_TITLE } from '../index';
|
||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { createRouterWrapper } from '../../../utils/test/react-router-dom';
|
||||||
|
import App from '../../../App';
|
||||||
|
|
||||||
|
describe('HomePage component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<HomePage />, MemoryRouter);
|
||||||
|
await expectPageTitle(HOME_PAGE_TITLE);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirect to login with not authenticated user', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<App />, createRouterWrapper('/home'));
|
||||||
|
await expectRedirectedToLoginPage(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/components/home/__tests__/home.tsx
Normal file
10
src/components/home/__tests__/home.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Home from '../home';
|
||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
describe('Home component', () => {
|
||||||
|
test('successful render introducer component', async () => {
|
||||||
|
const result = renderWithClient(<Home />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('introducer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/components/home/__tests__/introducer.test.tsx
Normal file
47
src/components/home/__tests__/introducer.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import Introducer from '../introducer';
|
||||||
|
import { mockedIntroducer } from '../../../mocks/data';
|
||||||
|
import { IntroducerType } from '../../../api/introducer';
|
||||||
|
|
||||||
|
describe('Introducer component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<Introducer introducer={mockedIntroducer} />);
|
||||||
|
const testIds = [
|
||||||
|
'introducer',
|
||||||
|
'code',
|
||||||
|
'share',
|
||||||
|
'discount',
|
||||||
|
'valid-days',
|
||||||
|
'student-count',
|
||||||
|
'share-price',
|
||||||
|
'paid-price'
|
||||||
|
];
|
||||||
|
for (const testId of testIds) {
|
||||||
|
expect(await result.findByTestId(testId)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const introducerParts = [
|
||||||
|
{ key: 'code', getValue: (introducer: IntroducerType) => introducer.code.text },
|
||||||
|
{
|
||||||
|
key: 'student-count',
|
||||||
|
getValue: (introducer: IntroducerType) => introducer.studentCount.toString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'share-price',
|
||||||
|
getValue: (introducer: IntroducerType) => introducer.sharePrice.toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paid-price',
|
||||||
|
getValue: (introducer: IntroducerType) => introducer.paidPrice.toLocaleString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
for (const { key, getValue } of introducerParts) {
|
||||||
|
test(`render introdcer ${key}`, async () => {
|
||||||
|
const result = renderWithClient(<Introducer introducer={mockedIntroducer} />);
|
||||||
|
const element = await result.findByTestId(key);
|
||||||
|
expect(element).toBeInTheDocument();
|
||||||
|
expect(element.innerHTML).toContain(getValue(mockedIntroducer));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
10
src/components/home/home.tsx
Normal file
10
src/components/home/home.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useIntroducer } from '../../hooks/react-query/use-introducer';
|
||||||
|
import Introducer from './introducer';
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const { isSuccess, data: introducer } = useIntroducer();
|
||||||
|
if (isSuccess) return <Introducer introducer={introducer} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
16
src/components/home/index.tsx
Normal file
16
src/components/home/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import Home from './home';
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<MainLayout loginRequired introducerRequired title={HOME_PAGE_TITLE}>
|
||||||
|
<div data-testid='home'>
|
||||||
|
<Home />
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOME_PAGE_TITLE = 'کد معرف';
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
74
src/components/home/introducer.tsx
Normal file
74
src/components/home/introducer.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Segment from '../shared/segment';
|
||||||
|
import { IntroducerType } from '../../api/introducer';
|
||||||
|
import useCopyToClipboard from '../../hooks/index/use-copy-to-clipboard';
|
||||||
|
import Button from '../shared/buttons/button';
|
||||||
|
|
||||||
|
export type IntroducerPropsType = { introducer: IntroducerType };
|
||||||
|
|
||||||
|
function Introducer({ introducer }: IntroducerPropsType) {
|
||||||
|
const { copy, isCopied } = useCopyToClipboard();
|
||||||
|
return (
|
||||||
|
<Segment padded dataTestId='introducer'>
|
||||||
|
<div data-testid='code' className='mb-8'>
|
||||||
|
<span className='font-medium'>کد معرف:</span>
|
||||||
|
<Button
|
||||||
|
circular
|
||||||
|
size='lg'
|
||||||
|
color='indigo'
|
||||||
|
colorWeight={700}
|
||||||
|
onClick={async () => {
|
||||||
|
if (await copy(introducer.code?.text)) {
|
||||||
|
toast.success('کد معرف شما با موفقیت کپی شد.');
|
||||||
|
} else {
|
||||||
|
toast.error('خطا: متاسفانه کد معرف شما کپی نشد.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={classNames(
|
||||||
|
'mx-auto my-6 bg-gradient-radial',
|
||||||
|
'transition-all duration-500 ease-in-out',
|
||||||
|
{ 'from-green-500 via-green-600 to-emerald-700': isCopied },
|
||||||
|
{ 'from-sky-800 via-sky-700 to-indigo-700': !isCopied }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isCopied && 'کپی'}
|
||||||
|
{!isCopied && <br />}
|
||||||
|
{introducer.code?.text}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p data-testid='share' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>سهم معرف:</span>
|
||||||
|
<span>{introducer.code.share}</span>
|
||||||
|
<span>%</span>
|
||||||
|
</p>
|
||||||
|
<p data-testid='discount' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>تخفیف داوطلب:</span>
|
||||||
|
<span>{introducer.code.discount}</span>
|
||||||
|
<span>%</span>
|
||||||
|
</p>
|
||||||
|
<p data-testid='valid-days' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>مدت اعتبار برای هر داوطلب:</span>
|
||||||
|
{introducer.code.validDays}
|
||||||
|
<span>روز</span>
|
||||||
|
</p>
|
||||||
|
<p data-testid='student-count' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>تعداد داوطلبان معرفی شده:</span>
|
||||||
|
<span>{introducer.studentCount}</span>
|
||||||
|
<span>نفر</span>
|
||||||
|
</p>
|
||||||
|
<p data-testid='share-price' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>کل درآمد معرف:</span>
|
||||||
|
{introducer.sharePrice.toLocaleString()}
|
||||||
|
<span> ریال</span>
|
||||||
|
</p>
|
||||||
|
<p data-testid='paid-price' className='my-2 flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>دریافتی معرف:</span>
|
||||||
|
{introducer.paidPrice.toLocaleString()}
|
||||||
|
<span> ریال</span>
|
||||||
|
</p>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Introducer;
|
||||||
20
src/components/layouts/main/__tests__/auth-header.test.tsx
Normal file
20
src/components/layouts/main/__tests__/auth-header.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import AuthHeader from '../auth-header';
|
||||||
|
import { mockFailureResponse } from '../../../../mocks/server';
|
||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
describe('AuthHeader component', () => {
|
||||||
|
test('successful query component', async () => {
|
||||||
|
const result = renderWithClient(<AuthHeader />, MemoryRouter);
|
||||||
|
|
||||||
|
expect(await result.findByText(/تست/i)).toBeInTheDocument();
|
||||||
|
expect(await result.findByText(/خروج/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure query component', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<AuthHeader />, MemoryRouter);
|
||||||
|
|
||||||
|
expect(await result.findByText(/ورود/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
9
src/components/layouts/main/__tests__/footer.test.tsx
Normal file
9
src/components/layouts/main/__tests__/footer.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import Footer from '../footer';
|
||||||
|
|
||||||
|
describe('Footer component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = render(<Footer />);
|
||||||
|
expect(await result.findByTestId('footer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/components/layouts/main/__tests__/header.test.tsx
Normal file
16
src/components/layouts/main/__tests__/header.test.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import Header from '../header';
|
||||||
|
|
||||||
|
describe('Header component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<Header />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('header')).toBeInTheDocument();
|
||||||
|
expect(await result.findByTestId('header-nav')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful query component', async () => {
|
||||||
|
const result = renderWithClient(<Header />, MemoryRouter);
|
||||||
|
expect(await result.findByText(/Med Moshaver/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/components/layouts/main/__tests__/main-layout.test.tsx
Normal file
21
src/components/layouts/main/__tests__/main-layout.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import MainLayout from '../index';
|
||||||
|
import { expectPageTestIds, expectPageTitle } from '../../../../utils/test/expects';
|
||||||
|
|
||||||
|
describe('MainLayout component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const title = 'title';
|
||||||
|
const result = renderWithClient(
|
||||||
|
<MainLayout loginRequired={false} title={title} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
await expectPageTitle(title);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render loader component', async () => {
|
||||||
|
const result = renderWithClient(<MainLayout loginRequired />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('loader')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/components/layouts/main/auth-header.tsx
Normal file
59
src/components/layouts/main/auth-header.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import NavLink from '../../shared/buttons/nav-link';
|
||||||
|
import Button from '../../shared/buttons/button';
|
||||||
|
import ButtonGroup from '../../shared/buttons/button-group';
|
||||||
|
import { useUser } from '../../../hooks/react-query/use-user';
|
||||||
|
import { useLogoutMutation } from '../../../hooks/react-query/use-auth';
|
||||||
|
import { getLoginRedirectURL } from '../../utils/url';
|
||||||
|
|
||||||
|
type AuthHeaderProps = { className?: string };
|
||||||
|
|
||||||
|
function AuthHeader(props: AuthHeaderProps) {
|
||||||
|
const history = useHistory();
|
||||||
|
const { status, data: user } = useUser();
|
||||||
|
const { mutate: logoutUser } = useLogoutMutation();
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<ButtonGroup className={props.className}>
|
||||||
|
<NavLink
|
||||||
|
to={getLoginRedirectURL(history.location.pathname, history.location.search)}
|
||||||
|
color='teal'
|
||||||
|
colorWeight={600}
|
||||||
|
content='ورود'
|
||||||
|
className={props.className}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
to='/register'
|
||||||
|
color='teal'
|
||||||
|
colorWeight={600}
|
||||||
|
content='ثبت نام'
|
||||||
|
className={props.className}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup className={props.className}>
|
||||||
|
<NavLink
|
||||||
|
to='/home'
|
||||||
|
color='teal'
|
||||||
|
colorWeight={600}
|
||||||
|
content={user?.name || user?.mobile}
|
||||||
|
loading={status === 'loading'}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
content='خروج'
|
||||||
|
color='red'
|
||||||
|
colorWeight={500}
|
||||||
|
onClick={logoutUser}
|
||||||
|
loading={status === 'loading'}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthHeader;
|
||||||
5
src/components/layouts/main/footer.tsx
Normal file
5
src/components/layouts/main/footer.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function Footer() {
|
||||||
|
return <footer data-testid='footer' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
87
src/components/layouts/main/header.tsx
Normal file
87
src/components/layouts/main/header.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { MenuIcon } from '@heroicons/react/outline';
|
||||||
|
import { XIcon } from '@heroicons/react/solid';
|
||||||
|
import AuthHeader from './auth-header';
|
||||||
|
import NavLink from '../../shared/buttons/nav-link';
|
||||||
|
|
||||||
|
export type HeaderPropsType = { isAuthenticated: boolean };
|
||||||
|
|
||||||
|
function Header({ isAuthenticated }: HeaderPropsType) {
|
||||||
|
const [isHidden, setIsHidden] = useState(true);
|
||||||
|
const iconSize = 34;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header data-testid='header' className='sticky top-0 z-50'>
|
||||||
|
<nav
|
||||||
|
data-testid='header-nav'
|
||||||
|
className='flex flex-wrap items-center justify-between bg-teal-800 p-2'
|
||||||
|
>
|
||||||
|
<NavLink to='/' color='transparent' wrapperClassName='hidden md:block'>
|
||||||
|
<p className='text-lg font-bold'>Med Moshaver</p>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<AuthHeader className='mx-2 md:order-last' />
|
||||||
|
|
||||||
|
<div className='flex md:hidden'>
|
||||||
|
<div className='flex md:hidden'>
|
||||||
|
<button id='hamburger' onClick={() => setIsHidden(isHidden => !isHidden)}>
|
||||||
|
<MenuIcon
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
className={classNames(
|
||||||
|
'toggle block',
|
||||||
|
{ hidden: !isHidden },
|
||||||
|
'rounded-md border-2 border-yellow-400 stroke-current p-1 text-yellow-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<XIcon
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
className={classNames(
|
||||||
|
'toggle block',
|
||||||
|
{ hidden: isHidden },
|
||||||
|
'rounded-md border-2 border-yellow-400 stroke-current p-1 text-yellow-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'toggle',
|
||||||
|
{ hidden: isHidden },
|
||||||
|
'text-bold mt-5 w-full text-right md:mt-0 md:flex md:w-auto',
|
||||||
|
'border-t-2 border-blue-900 md:border-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{links
|
||||||
|
.filter(link => !link.auth || isAuthenticated)
|
||||||
|
.map(link => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className={`
|
||||||
|
w-100 block rounded-none border-t-2 border-yellow-400
|
||||||
|
px-1 py-1 md:inline-block md:border-none
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<NavLink to={link.href} textSize='base' color='transparent'>
|
||||||
|
{link.name}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ id: 1, name: 'معرفی سامانه', href: '/', auth: false },
|
||||||
|
{ id: 2, name: 'کد معرف', href: '/home', auth: true },
|
||||||
|
{ id: 3, name: 'مالی', href: '/financial', auth: true },
|
||||||
|
{ id: 4, name: 'شرایط و قوانین', href: '/terms-and-conditions', auth: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default Header;
|
||||||
75
src/components/layouts/main/index.tsx
Normal file
75
src/components/layouts/main/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { Redirect, RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
|
import Header from './header';
|
||||||
|
import Container from '../../shared/container';
|
||||||
|
import Footer from './footer';
|
||||||
|
import ScrollToTop from '../../shared/buttons/scroll-to-top';
|
||||||
|
import Loader from '../../shared/loader';
|
||||||
|
import { getLoginRedirectURL } from '../../utils/url';
|
||||||
|
import { useUser } from '../../../hooks/react-query/use-user';
|
||||||
|
import { useIntroducer } from '../../../hooks/react-query/use-introducer';
|
||||||
|
import CreateIntroducerForm from '../../shared/create-introducer-form';
|
||||||
|
|
||||||
|
export type MainLayoutProps = RouteComponentProps & {
|
||||||
|
title?: string;
|
||||||
|
loginRequired?: boolean;
|
||||||
|
introducerRequired?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MainLayout({
|
||||||
|
title,
|
||||||
|
loginRequired = true,
|
||||||
|
introducerRequired = false,
|
||||||
|
location,
|
||||||
|
children
|
||||||
|
}: MainLayoutProps) {
|
||||||
|
const { status: userStatus } = useUser();
|
||||||
|
const { status: introducerStatus } = useIntroducer(
|
||||||
|
introducerRequired && userStatus === 'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (title) document.title = title;
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (introducerRequired && introducerStatus === 'error')
|
||||||
|
return (
|
||||||
|
<div className='mx-auto w-full w-full sm:w-4/5 md:w-3/4 lg:w-2/3'>
|
||||||
|
<CreateIntroducerForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(introducerRequired && ['indle', 'loading'].includes(introducerStatus)) ||
|
||||||
|
(loginRequired && userStatus === 'loading')
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginRequired && userStatus === 'error')
|
||||||
|
return <Redirect to={getLoginRedirectURL(location.pathname, location.search)} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex h-screen flex-col'>
|
||||||
|
<Header isAuthenticated={userStatus === 'success'} />
|
||||||
|
<main data-testid='main' className='flex-1 py-4'>
|
||||||
|
<Container>{renderContent()}</Container>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
<ScrollToTop />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(MainLayout);
|
||||||
13
src/components/login/__tests__/login-form.test.tsx
Normal file
13
src/components/login/__tests__/login-form.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
import LoginForm from '../form';
|
||||||
|
|
||||||
|
describe('LoginForm component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
const result = renderWithClient(<LoginForm />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('login-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('ورود')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/components/login/__tests__/login-page.test.tsx
Normal file
14
src/components/login/__tests__/login-page.test.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import LoginPage, { LOGIN_PAGE_TITLE } from '../index';
|
||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { expectPageTestIds, expectPageTitle } from '../../../utils/test/expects';
|
||||||
|
|
||||||
|
describe('LoginPage Component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<LoginPage />, MemoryRouter);
|
||||||
|
expectPageTitle(LOGIN_PAGE_TITLE);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
expect(await result.findByTestId('login-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('ورود')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/components/login/form.tsx
Normal file
65
src/components/login/form.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import Segment from '../shared/segment';
|
||||||
|
import { mobile, required } from '../shared/forms/validations';
|
||||||
|
import FormInput from '../shared/forms/input';
|
||||||
|
import Button from '../shared/buttons/button';
|
||||||
|
import Alert from '../shared/alert';
|
||||||
|
import ButtonGroup from '../shared/buttons/button-group';
|
||||||
|
import { useLoginMutation } from '../../hooks/react-query/use-auth';
|
||||||
|
import { useFormErrorMessage } from '../../hooks/index/use-form-error-message';
|
||||||
|
|
||||||
|
type LoginData = { mobile: string; password: string };
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const { formErrorMessage, setFormErrorMessage } = useFormErrorMessage();
|
||||||
|
const { mutate: loginUser } = useLoginMutation();
|
||||||
|
|
||||||
|
function setFormError() {
|
||||||
|
setFormErrorMessage('شماره تلفن یا رمز عبور نادرست است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(data: LoginData) {
|
||||||
|
await loginUser(data, { onError: setFormError });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment dataTestId='login'>
|
||||||
|
<Formik initialValues={{ mobile: '', password: '' }} onSubmit={submit}>
|
||||||
|
{({ dirty }: { dirty: boolean }) => (
|
||||||
|
<Fragment>
|
||||||
|
{dirty && formErrorMessage && (
|
||||||
|
<Alert content={formErrorMessage} className='mt-3 mb-5' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form data-testid='login-form' className='grid grid-cols-1 gap-5 py-3'>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='mobile'
|
||||||
|
label='شماره تلفن همراه'
|
||||||
|
placeholder='09121234567'
|
||||||
|
component={FormInput}
|
||||||
|
type='tel'
|
||||||
|
maxLength={11}
|
||||||
|
validate={mobile}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='password'
|
||||||
|
label='رمز عبور'
|
||||||
|
type='password'
|
||||||
|
component={FormInput}
|
||||||
|
validate={required}
|
||||||
|
/>
|
||||||
|
<ButtonGroup float>
|
||||||
|
<Button type='submit' color='green' content='ورود' />
|
||||||
|
</ButtonGroup>
|
||||||
|
</Form>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
27
src/components/login/index.tsx
Normal file
27
src/components/login/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import LoginForm from './form';
|
||||||
|
import useQueryParams from '../../hooks/index/use-query-params';
|
||||||
|
import { useUser } from '../../hooks/react-query/use-user';
|
||||||
|
import { getNextRedirectURL } from '../utils/url';
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const { isAuthenticated } = useUser();
|
||||||
|
const { next } = useQueryParams();
|
||||||
|
|
||||||
|
if (isAuthenticated) return <Redirect to={getNextRedirectURL(next)} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout loginRequired={false} title={LOGIN_PAGE_TITLE}>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<div className='w-full md:w-3/4 lg:w-2/3 xl:w-1/2 2xl:w-1/3'>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOGIN_PAGE_TITLE = 'ورود';
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
49
src/components/register/__tests__/mobile-form.test.tsx
Normal file
49
src/components/register/__tests__/mobile-form.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { mockFailureResponse } from '../../../mocks/server';
|
||||||
|
import { mockedMobile } from '../../../mocks/data';
|
||||||
|
import * as tempUserAPI from '../../../api/temp-user';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import MobileForm from '../mobile-form';
|
||||||
|
|
||||||
|
describe('MobileForm component', () => {
|
||||||
|
const mockedHandleSubmit = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render component', async () => {
|
||||||
|
mockFailureResponse();
|
||||||
|
|
||||||
|
const result = renderWithClient(<MobileForm onSubmit={mockedHandleSubmit} />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('mobile-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('تایید')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call register user function with correct arguments', async () => {
|
||||||
|
const createTempUser = jest.spyOn(tempUserAPI, 'createTempUser');
|
||||||
|
|
||||||
|
const result = renderWithClient(<MobileForm onSubmit={mockedHandleSubmit} />, MemoryRouter);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(result.getByTestId('mobile-field'), mockedMobile);
|
||||||
|
await user.click(await result.getByRole('button', { name: /تایید/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createTempUser).toHaveBeenCalledWith({ mobile: mockedMobile }));
|
||||||
|
await waitFor(() => expect(mockedHandleSubmit).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form errors', async () => {
|
||||||
|
const result = renderWithClient(<MobileForm onSubmit={mockedHandleSubmit} />, MemoryRouter);
|
||||||
|
const mobileField = await result.getByTestId('mobile-field');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(mobileField, mockedMobile.substring(2));
|
||||||
|
await user.tab();
|
||||||
|
|
||||||
|
expect(await result.getByTestId('mobile-field-error')).toHaveTextContent(
|
||||||
|
'لطفا یک شماره تلفن همراه معتبر وارد نمایید.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
src/components/register/__tests__/register-content.test.tsx
Normal file
72
src/components/register/__tests__/register-content.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import RegisterContent from '../register-content';
|
||||||
|
import {
|
||||||
|
mockedAuthKey,
|
||||||
|
mockedMobile,
|
||||||
|
mockedRestrictedMobile,
|
||||||
|
mockedSmsKey
|
||||||
|
} from '../../../mocks/data';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { act } from '@testing-library/react-hooks';
|
||||||
|
import { DEFAULT_TIME_LEFT } from '../sms-key/resend-view';
|
||||||
|
|
||||||
|
describe('RegisterContent component', () => {
|
||||||
|
test('successful render component step 1', async () => {
|
||||||
|
const result = renderWithClient(<RegisterContent />, MemoryRouter);
|
||||||
|
expect(await result.findByTestId('mobile-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('تایید')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render component step 2', async () => {
|
||||||
|
const result = renderWithClient(<RegisterContent startStep={1} defaultMobile={mockedMobile} />);
|
||||||
|
expect(await result.findByTestId('smskey-content')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('تایید')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render component step 3', async () => {
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterContent startStep={2} defaultMobile={mockedMobile} defaultAuthKey={mockedAuthKey} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
expect(await result.findByTestId('register-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('تایید')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call checkSmsKeyRequest function with error 403', async () => {
|
||||||
|
const data = { mobile: mockedRestrictedMobile, smsKey: mockedSmsKey };
|
||||||
|
|
||||||
|
const result = renderWithClient(<RegisterContent startStep={1} defaultMobile={data.mobile} />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(result.getByTestId('smsKey-field'), data.smsKey);
|
||||||
|
await user.click(await result.getByRole('button', { name: /تایید/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.getByRole('alert')).toHaveTextContent(
|
||||||
|
'محدودیت وارد کردن کد پیامکی اشتباه برای شما به پایان رسیده است.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call sendSmsKeyRequest function with error 403', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const data = { mobile: mockedRestrictedMobile };
|
||||||
|
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterContent startStep={1} defaultMobile={data.mobile} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersToNextTimer(DEFAULT_TIME_LEFT));
|
||||||
|
jest.useRealTimers();
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(await result.getByRole('button', { name: /ارسال مجدد کد/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.getByRole('alert')).toHaveTextContent(
|
||||||
|
'محدودیت ارسال کد پیامکی برای شما به پایان رسیده است.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/components/register/__tests__/register-form.test.tsx
Normal file
121
src/components/register/__tests__/register-form.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import RegisterForm from '../register-form';
|
||||||
|
import { mockedAuthKey, mockedMobile, mockedRegisterUserData } from '../../../mocks/data';
|
||||||
|
import * as userAPI from '../../../api/user';
|
||||||
|
import * as introducerAPI from '../../../api/introducer';
|
||||||
|
import * as authAPI from '../../../api/auth';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
describe('RegisterForm component', () => {
|
||||||
|
const data = mockedRegisterUserData;
|
||||||
|
const { iban, ...userData } = data;
|
||||||
|
const { mobile, password } = userData;
|
||||||
|
|
||||||
|
const mockedHandleReset = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterForm mobile={mockedMobile} authKey={mockedAuthKey} reset={mockedHandleReset} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
expect(await result.findByTestId('register-form')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText(mockedMobile)).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('تایید')).toBeInTheDocument();
|
||||||
|
mockedHandleReset.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call registerUser functions with correct arguments', async () => {
|
||||||
|
const createUserSpy = jest.spyOn(userAPI, 'createUser');
|
||||||
|
const createIntroducerSpy = jest.spyOn(introducerAPI, 'createIntroducer');
|
||||||
|
const fetchTokenSpy = jest.spyOn(authAPI, 'fetchToken');
|
||||||
|
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterForm mobile={mockedMobile} authKey={mockedAuthKey} reset={mockedHandleReset} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
await user.type(result.getByTestId('firstName-field'), data.firstName);
|
||||||
|
await user.type(result.getByTestId('lastName-field'), data.lastName);
|
||||||
|
await user.selectOptions(await result.getByTestId('gender-field'), data.gender);
|
||||||
|
await user.type(result.getByTestId('password-field'), data.password);
|
||||||
|
await user.type(result.getByTestId('rePassword-field'), data.password);
|
||||||
|
if (data.iban) await user.type(result.getByTestId('iban-field'), data.iban);
|
||||||
|
await user.click(await result.getByTestId('termsAndConditions-field'));
|
||||||
|
await user.click(await result.getByRole('button', { name: /تایید/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createUserSpy).toHaveBeenCalledWith(userData));
|
||||||
|
await waitFor(() => expect(createIntroducerSpy).toHaveBeenCalledWith({ iban }));
|
||||||
|
await waitFor(() => expect(fetchTokenSpy).toHaveBeenCalledWith({ mobile, password }));
|
||||||
|
await waitFor(() => expect(mockedHandleReset).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form errors', async () => {
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterForm mobile={mockedMobile} authKey={mockedAuthKey} reset={mockedHandleReset} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
user.click(await result.getByRole('button', { name: /تایید/i }));
|
||||||
|
|
||||||
|
expect(mockedHandleReset).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (const fieldName of [
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'gender',
|
||||||
|
'password',
|
||||||
|
'rePassword',
|
||||||
|
'termsAndConditions'
|
||||||
|
]) {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.getByTestId(`${fieldName}-field-error`)).toHaveTextContent(
|
||||||
|
fieldName === 'termsAndConditions'
|
||||||
|
? 'پذیرفتن شرایط و قوانین الزامی است.'
|
||||||
|
: 'وارد کردن این فیلد الزامی است.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.selectOptions(await result.getByTestId('gender-field'), '');
|
||||||
|
await user.tab();
|
||||||
|
expect(await result.getByTestId('gender-field-error')).toHaveTextContent(
|
||||||
|
'وارد کردن این فیلد الزامی است.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form verification', async () => {
|
||||||
|
const createUserSpy = jest.spyOn(userAPI, 'createUser');
|
||||||
|
const createIntroducerSpy = jest.spyOn(introducerAPI, 'createIntroducer');
|
||||||
|
const fetchTokenSpy = jest.spyOn(authAPI, 'fetchToken');
|
||||||
|
|
||||||
|
const result = renderWithClient(
|
||||||
|
<RegisterForm mobile={mockedMobile} authKey={mockedAuthKey} reset={mockedHandleReset} />,
|
||||||
|
MemoryRouter
|
||||||
|
);
|
||||||
|
await user.type(result.getByTestId('firstName-field'), data.firstName);
|
||||||
|
await user.type(result.getByTestId('lastName-field'), data.lastName);
|
||||||
|
await user.selectOptions(result.getByTestId('gender-field'), data.gender);
|
||||||
|
await user.type(result.getByTestId('password-field'), data.password);
|
||||||
|
await user.type(result.getByTestId('rePassword-field'), data.password.substring(1));
|
||||||
|
if (data.iban) await user.type(result.getByTestId('iban-field'), data.iban);
|
||||||
|
await user.click(result.getByTestId('termsAndConditions-field'));
|
||||||
|
await user.click(result.getByRole('button', { name: /تایید/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.getByTestId(`rePassword-field-error`)).toHaveTextContent(
|
||||||
|
'رمزهای عبور غیر یکسان هستند.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(createUserSpy).not.toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(createIntroducerSpy).not.toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(fetchTokenSpy).not.toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(mockedHandleReset).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/components/register/__tests__/register-page.test.tsx
Normal file
13
src/components/register/__tests__/register-page.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { renderWithClient } from '../../../utils/test/react-query';
|
||||||
|
import { expectPageTestIds, expectPageTitle } from '../../../utils/test/expects';
|
||||||
|
import RegisterPage, { REGISTER_PAGE_TITLE } from '../index';
|
||||||
|
|
||||||
|
describe('RegisterPage component', () => {
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(<RegisterPage />, MemoryRouter);
|
||||||
|
expectPageTitle(REGISTER_PAGE_TITLE);
|
||||||
|
await expectPageTestIds(result);
|
||||||
|
expect(await result.findByTestId('register')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/components/register/index.tsx
Normal file
24
src/components/register/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import { useUser } from '../../hooks/react-query/use-user';
|
||||||
|
import { getNextRedirectURL } from '../utils/url';
|
||||||
|
import RegisterContent from './register-content';
|
||||||
|
|
||||||
|
function RegisterPage() {
|
||||||
|
const { isAuthenticated } = useUser();
|
||||||
|
if (isAuthenticated) return <Redirect to={getNextRedirectURL()} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout loginRequired={false} title={REGISTER_PAGE_TITLE}>
|
||||||
|
<div data-testid='register' className='flex justify-center'>
|
||||||
|
<div className='w-full w-full sm:w-3/4 lg:w-1/2'>
|
||||||
|
<RegisterContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGISTER_PAGE_TITLE = 'ثبت نام';
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
61
src/components/register/mobile-form.tsx
Normal file
61
src/components/register/mobile-form.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||||
|
import Segment from '../shared/segment';
|
||||||
|
import Alert from '../shared/alert';
|
||||||
|
import FormInput from '../shared/forms/input';
|
||||||
|
import Button from '../shared/buttons/button';
|
||||||
|
import { mobile } from '../shared/forms/validations';
|
||||||
|
import { useCreateTempUserMutation } from '../../hooks/react-query/use-temp-user';
|
||||||
|
import { CreateTempUserDataType } from '../../api/temp-user';
|
||||||
|
import { useFormErrorMessage } from '../../hooks/index/use-form-error-message';
|
||||||
|
|
||||||
|
export type MobileFormData = CreateTempUserDataType;
|
||||||
|
export type MobileFormProps = { onSubmit: (data: MobileFormData) => void };
|
||||||
|
|
||||||
|
function MobileForm({ onSubmit }: MobileFormProps) {
|
||||||
|
const { formErrorMessage, setFormError } = useFormErrorMessage();
|
||||||
|
const { mutate: createTempUser } = useCreateTempUserMutation();
|
||||||
|
|
||||||
|
async function submit(values: MobileFormData, { setErrors }: FormikHelpers<MobileFormData>) {
|
||||||
|
createTempUser(values, {
|
||||||
|
onSuccess: onSubmit,
|
||||||
|
onError: error => {
|
||||||
|
setFormError(error);
|
||||||
|
setErrors({ mobile: error.data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment dataTestId='mobile-form'>
|
||||||
|
<Formik initialValues={{ mobile: '' }} onSubmit={submit}>
|
||||||
|
{({ dirty, isSubmitting }) => (
|
||||||
|
<>
|
||||||
|
{dirty && formErrorMessage && (
|
||||||
|
<Alert content={formErrorMessage} className='mt-3 mb-5' />
|
||||||
|
)}
|
||||||
|
<Form noValidate className='grid grid-cols-1 gap-5 py-3'>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='mobile'
|
||||||
|
label='شماره تلفن همراه'
|
||||||
|
placeholder='09121234567'
|
||||||
|
component={FormInput}
|
||||||
|
type='tel'
|
||||||
|
maxLength={11}
|
||||||
|
validate={mobile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='green'
|
||||||
|
content='تایید'
|
||||||
|
disabled={!dirty || isSubmitting}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileForm;
|
||||||
90
src/components/register/register-content.tsx
Normal file
90
src/components/register/register-content.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import MobileForm, { MobileFormData } from './mobile-form';
|
||||||
|
import SmsKey from './sms-key';
|
||||||
|
import RegisterForm from './register-form';
|
||||||
|
import { CheckSmsKeyRequestReturnType } from '../../api/temp-user';
|
||||||
|
import { useFormErrorMessage } from '../../hooks/index/use-form-error-message';
|
||||||
|
import Alert from '../shared/alert';
|
||||||
|
|
||||||
|
export type RegisterContentPropsType = {
|
||||||
|
startStep?: number;
|
||||||
|
defaultMobile?: string | null;
|
||||||
|
defaultAuthKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RegisterContent({
|
||||||
|
startStep = 0,
|
||||||
|
defaultMobile = null,
|
||||||
|
defaultAuthKey = null
|
||||||
|
}: RegisterContentPropsType) {
|
||||||
|
const [step, setStep] = useState<number>(startStep);
|
||||||
|
const [mobile, setMobile] = useState<string | null>(defaultMobile);
|
||||||
|
const [authKey, setAuthKey] = useState<string | null>(defaultAuthKey);
|
||||||
|
const { formErrorMessage: errorMessage, setFormError: setError } = useFormErrorMessage();
|
||||||
|
|
||||||
|
if (errorMessage) return <Alert content={errorMessage} />;
|
||||||
|
|
||||||
|
if (step === MOBILE || !mobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MobileForm
|
||||||
|
onSubmit={({ mobile }: MobileFormData) => {
|
||||||
|
setMobile(mobile);
|
||||||
|
setStep(SMS_KEY);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className='m-6 text-center'>
|
||||||
|
<span className='font-medium text-red-500'>توجه: </span>
|
||||||
|
<span> اگر هم اکنون به عنوان </span>
|
||||||
|
<span className='font-medium'>داوطلب</span>
|
||||||
|
<span> عضو سایت </span>
|
||||||
|
<span className='font-medium'>فلش کارتهای مدمشاور</span>
|
||||||
|
<span> هستید نیاز به ثبتنام مجدد ندارید و با استفاده از دکمه </span>
|
||||||
|
<Link to='/login' className='font-medium text-indigo-500'>
|
||||||
|
ورود
|
||||||
|
</Link>
|
||||||
|
<span> وارد شوید و در غیر این صورت با استفاده از فرم بالا ثبتنام کنید.</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === SMS_KEY || !authKey) {
|
||||||
|
return (
|
||||||
|
<SmsKey
|
||||||
|
mobile={mobile}
|
||||||
|
onSubmit={({ authKey }: CheckSmsKeyRequestReturnType) => {
|
||||||
|
setAuthKey(authKey);
|
||||||
|
setStep(REGISTER);
|
||||||
|
}}
|
||||||
|
onBack={() => setStep(MOBILE)}
|
||||||
|
onError={error => {
|
||||||
|
setError(error);
|
||||||
|
setMobile(null);
|
||||||
|
setStep(MOBILE);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === REGISTER) {
|
||||||
|
return (
|
||||||
|
<RegisterForm
|
||||||
|
mobile={mobile}
|
||||||
|
authKey={authKey}
|
||||||
|
reset={() => {
|
||||||
|
setStep(MOBILE);
|
||||||
|
setMobile(null);
|
||||||
|
setAuthKey(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [MOBILE, SMS_KEY, REGISTER] = [0, 1, 2];
|
||||||
|
|
||||||
|
export default RegisterContent;
|
||||||
152
src/components/register/register-form.tsx
Normal file
152
src/components/register/register-form.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import Segment from '../shared/segment';
|
||||||
|
import {
|
||||||
|
gender,
|
||||||
|
password,
|
||||||
|
required,
|
||||||
|
requiredTermsAndConditions
|
||||||
|
} from '../shared/forms/validations';
|
||||||
|
import FormInput from '../shared/forms/input';
|
||||||
|
import FormSelect from '../shared/forms/select';
|
||||||
|
import Button from '../shared/buttons/button';
|
||||||
|
import Alert from '../shared/alert';
|
||||||
|
import ButtonGroup from '../shared/buttons/button-group';
|
||||||
|
import { useFormErrorMessage } from '../../hooks/index/use-form-error-message';
|
||||||
|
import { useRegisterMutation, UseRegisterMutationDataType } from '../../hooks/react-query/use-auth';
|
||||||
|
import FormCheckbox from '../shared/forms/checkbox';
|
||||||
|
|
||||||
|
type RegisterFormDataType = UseRegisterMutationDataType & {
|
||||||
|
rePassword: string;
|
||||||
|
termsAndConditions: boolean;
|
||||||
|
};
|
||||||
|
type RegisterFormPropsType = {
|
||||||
|
mobile: string;
|
||||||
|
authKey: string;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RegisterForm({ mobile, authKey, reset }: RegisterFormPropsType) {
|
||||||
|
const { formErrorMessage, setFormError } = useFormErrorMessage();
|
||||||
|
const { mutate: registerUser } = useRegisterMutation();
|
||||||
|
|
||||||
|
function validate({ password, rePassword }: RegisterFormDataType) {
|
||||||
|
const errors: { rePassword?: string } = {};
|
||||||
|
if (password !== rePassword) errors.rePassword = 'رمزهای عبور غیر یکسان هستند.';
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(data: RegisterFormDataType) {
|
||||||
|
const { rePassword, termsAndConditions, ...registerData } = data;
|
||||||
|
await registerUser(registerData, {
|
||||||
|
onSuccess: reset,
|
||||||
|
onError: setFormError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment dataTestId='register-form'>
|
||||||
|
<p className='my-4 text-center font-normal'>
|
||||||
|
<span>شماره تلفن همراه: </span>
|
||||||
|
<span>{mobile}</span>
|
||||||
|
</p>
|
||||||
|
<hr className='my-4' />
|
||||||
|
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
mobile,
|
||||||
|
authKey,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
gender: '',
|
||||||
|
password: '',
|
||||||
|
rePassword: '',
|
||||||
|
iban: '',
|
||||||
|
termsAndConditions: false
|
||||||
|
}}
|
||||||
|
onSubmit={submit}
|
||||||
|
validate={validate}
|
||||||
|
>
|
||||||
|
{({ dirty }: { dirty: boolean }) => (
|
||||||
|
<>
|
||||||
|
{dirty && formErrorMessage && (
|
||||||
|
<Alert content={formErrorMessage} className='mt-3 mb-5' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form noValidate data-testid='login-form' className='grid grid-cols-1 gap-5 py-3'>
|
||||||
|
<Field id='username' name='mobile' component={FormInput} type='hidden' />
|
||||||
|
<Field name='authKey' component={FormInput} type='hidden' />
|
||||||
|
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='firstName'
|
||||||
|
label='نام'
|
||||||
|
component={FormInput}
|
||||||
|
validate={required}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='lastName'
|
||||||
|
label='نام خانوادگی'
|
||||||
|
component={FormInput}
|
||||||
|
validate={required}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='gender'
|
||||||
|
label='جنسیت'
|
||||||
|
component={FormSelect}
|
||||||
|
options={GENDER_OPTIONS}
|
||||||
|
validate={gender}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='password'
|
||||||
|
label='رمز عبور'
|
||||||
|
type='password'
|
||||||
|
component={FormInput}
|
||||||
|
validate={password}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='rePassword'
|
||||||
|
label='تائید رمز عبور'
|
||||||
|
type='password'
|
||||||
|
component={FormInput}
|
||||||
|
validate={required}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<Field name='iban' component={FormInput} type='hidden' />
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='termsAndConditions'
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Link to='/terms-and-conditions' target='_blank' className='text-indigo-600'>
|
||||||
|
شرایط و قوانین{' '}
|
||||||
|
</Link>
|
||||||
|
<span>را مطالعه کردهام و آنها را میپذیرم.</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
component={FormCheckbox}
|
||||||
|
validate={requiredTermsAndConditions}
|
||||||
|
/>
|
||||||
|
<ButtonGroup float>
|
||||||
|
<Button type='submit' color='green' content='تایید' />
|
||||||
|
<Button type='submit' content='بازگشت' onClick={reset} />
|
||||||
|
</ButtonGroup>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENDER_OPTIONS = [
|
||||||
|
{ name: 'جنسیت', text: 'جنسیت', value: '' },
|
||||||
|
{ name: 'مرد', text: 'مرد', value: 'M' },
|
||||||
|
{ name: 'زن', text: 'زن', value: 'F' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default RegisterForm;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { mockedMobile } from '../../../../mocks/data';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import MobileView from '../mobile-view';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
describe('MobileView component', () => {
|
||||||
|
const mockedHandleBack = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = render(<MobileView mobile={mockedMobile} onBack={mockedHandleBack} />);
|
||||||
|
expect(await result.findByTestId('smskey-mobile-view')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('شماره تلفن همراه:')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText(mockedMobile)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await result.findByRole('button', { name: 'تغییر شماره تلفن همراه' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call onBack function with correct arguments', async () => {
|
||||||
|
const result = render(<MobileView mobile={mockedMobile} onBack={mockedHandleBack} />);
|
||||||
|
const actionButton = await result.findByRole('button', { name: 'تغییر شماره تلفن همراه' });
|
||||||
|
expect(actionButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(actionButton);
|
||||||
|
expect(mockedHandleBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { mockedMobile, mockedRestrictedMobile } from '../../../../mocks/data';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ResendView, { DEFAULT_TIME_LEFT } from '../resend-view';
|
||||||
|
import { act } from '@testing-library/react-hooks';
|
||||||
|
import { renderWithClient } from '../../../../utils/test/react-query';
|
||||||
|
import * as tempUserAPI from '../../../../api/temp-user';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
describe('ResendView component', () => {
|
||||||
|
const mockedHandleError = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful render component', async () => {
|
||||||
|
const result = renderWithClient(
|
||||||
|
<ResendView mobile={mockedMobile} onError={mockedHandleError} />
|
||||||
|
);
|
||||||
|
expect(await result.findByTestId('smskey-resend-view')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('زمان انتظار برای ارسال مجدد:')).toBeInTheDocument();
|
||||||
|
expect(await result.findByText(DEFAULT_TIME_LEFT)).toBeInTheDocument();
|
||||||
|
expect(await result.findByText('ثانیه')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersToNextTimer(DEFAULT_TIME_LEFT));
|
||||||
|
expect(await result.findByRole('button', { name: 'ارسال مجدد کد' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call sendSmsKey function with correct arguments', async () => {
|
||||||
|
const mockSendSmsKeyRequest = jest.spyOn(tempUserAPI, 'sendSmsKeyRequest');
|
||||||
|
|
||||||
|
const data = { mobile: mockedMobile };
|
||||||
|
const result = renderWithClient(
|
||||||
|
<ResendView mobile={data.mobile} onError={mockedHandleError} />
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersToNextTimer(DEFAULT_TIME_LEFT));
|
||||||
|
const actionButton = await result.findByRole('button', { name: 'ارسال مجدد کد' });
|
||||||
|
expect(actionButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(actionButton);
|
||||||
|
await expect(mockSendSmsKeyRequest).toHaveBeenCalledWith(data);
|
||||||
|
await waitFor(() => expect(mockedHandleError).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call onError function with correct arguments', async () => {
|
||||||
|
const mockSendSmsKeyRequest = jest.spyOn(tempUserAPI, 'sendSmsKeyRequest');
|
||||||
|
|
||||||
|
const data = { mobile: mockedRestrictedMobile };
|
||||||
|
const result = renderWithClient(
|
||||||
|
<ResendView mobile={data.mobile} onError={mockedHandleError} />
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersToNextTimer(DEFAULT_TIME_LEFT));
|
||||||
|
const actionButton = await result.findByRole('button', { name: 'ارسال مجدد کد' });
|
||||||
|
expect(actionButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(actionButton);
|
||||||
|
await expect(mockSendSmsKeyRequest).toHaveBeenCalledWith(data);
|
||||||
|
await waitFor(() => expect(mockedHandleError).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user