This commit is contained in:
2026-08-08 18:59:56 +03:30
commit a949fc3766
175 changed files with 16575 additions and 0 deletions

10
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View 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');
}

View 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
View 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}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
public/browserconfig.xml Normal file
View 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
View 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
View File

83
public/manifest.json Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

52
src/App.test.tsx Normal file
View 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
View 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;

View 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);
}
});
});

View 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);
}
});
});

View 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);
}
});
});

View 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('کد وارد شده اشتباه است.');
}
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
});
}

View 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');
});
});

View File

@@ -0,0 +1,7 @@
import MainLayout from '../layouts/main';
function DefaultPage() {
return <MainLayout loginRequired={false} />;
}
export default DefaultPage;

View File

@@ -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();
});
});

View 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);
});
});

View 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();
}
});
});

View 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;

View 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;

View 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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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();
});
});

View 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;

View 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);
});
});

View 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();
});
});

View 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));
});
}
});

View 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;

View 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;

View 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;

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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;

View File

@@ -0,0 +1,5 @@
function Footer() {
return <footer data-testid='footer' />;
}
export default Footer;

View 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;

View 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);

View 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();
});
});

View 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();
});
});

View 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;

View 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;

View 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(
'لطفا یک شماره تلفن همراه معتبر وارد نمایید.'
);
});
});

View 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(
'محدودیت‌ ارسال کد پیامکی برای شما به پایان رسیده است.'
)
);
});
});

View 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());
});
});

View 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();
});
});

View 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;

View 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;

View 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;

View 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;

View File

@@ -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();
});
});

View File

@@ -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