commit a949fc3766c6f4fe3f6d1fcdef7871be20dbb349 Author: mho3ein220 Date: Sat Aug 8 18:59:56 2026 +0330 start diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bd59ac6 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9058536 --- /dev/null +++ b/.eslintrc.json @@ -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" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..792d6a4 --- /dev/null +++ b/.prettierrc.json @@ -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 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b58e0af --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Getting Started with Create React App + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/package.json b/package.json new file mode 100644 index 0000000..160c375 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/public/assets/css/fontiran.css b/public/assets/css/fontiran.css new file mode 100644 index 0000000..28247ce --- /dev/null +++ b/public/assets/css/fontiran.css @@ -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'); +} diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..f3a59b4 --- /dev/null +++ b/public/assets/css/style.css @@ -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; +} diff --git a/public/assets/css/style.min.css b/public/assets/css/style.min.css new file mode 100644 index 0000000..3af9d1e --- /dev/null +++ b/public/assets/css/style.min.css @@ -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} \ No newline at end of file diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum).eot b/public/assets/fonts/eot/IRANSansWeb(FaNum).eot new file mode 100644 index 0000000..5a6fdd0 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum).eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot new file mode 100644 index 0000000..a012bc6 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot new file mode 100644 index 0000000..82525e5 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot new file mode 100644 index 0000000..f899008 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot new file mode 100644 index 0000000..117c2c8 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot new file mode 100644 index 0000000..9cd9b66 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf new file mode 100644 index 0000000..cd74265 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf new file mode 100644 index 0000000..0f84dbf Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf new file mode 100644 index 0000000..6fa2412 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf new file mode 100644 index 0000000..d9000c9 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf new file mode 100644 index 0000000..e20001c Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf new file mode 100644 index 0000000..0072606 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum).woff b/public/assets/fonts/woff/IRANSansWeb(FaNum).woff new file mode 100644 index 0000000..e34dfc4 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum).woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff new file mode 100644 index 0000000..bbbca77 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff new file mode 100644 index 0000000..05d933e Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff new file mode 100644 index 0000000..fe80d47 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff new file mode 100644 index 0000000..f13a870 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff new file mode 100644 index 0000000..9c653a4 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 new file mode 100644 index 0000000..30a377d Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 new file mode 100644 index 0000000..0b15441 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 new file mode 100644 index 0000000..f2353b2 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 new file mode 100644 index 0000000..562d663 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 new file mode 100644 index 0000000..a69f4d0 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 new file mode 100644 index 0000000..1114f71 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 differ diff --git a/public/browserconfig.xml b/public/browserconfig.xml new file mode 100644 index 0000000..e636aa5 --- /dev/null +++ b/public/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #ffffff + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7687d1b --- /dev/null +++ b/public/index.html @@ -0,0 +1,157 @@ + + + + + + + + + پنل همکاری در فروش فلش کارت های گروه مدمشاور + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ logo +
+ +
+
+
+ + + diff --git a/public/lib/.gitkeep b/public/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..6468425 --- /dev/null +++ b/public/manifest.json @@ -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" +} diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0966a9d --- /dev/null +++ b/public/mockServiceWorker.js @@ -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) + }) +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/voices/introduction.mp3 b/public/voices/introduction.mp3 new file mode 100644 index 0000000..c071023 Binary files /dev/null and b/public/voices/introduction.mp3 differ diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..c6b3d90 --- /dev/null +++ b/src/App.test.tsx @@ -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(, 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(, createRouterWrapper('/home')); + await expectRedirectedToLoginPage(result); + }); + + test('successful redirect register page to root page when user is authenticated', async () => { + const result = renderWithClient(, createRouterWrapper('/register')); + await expectRedirectedToHomePage(result); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ca42812 --- /dev/null +++ b/src/App.tsx @@ -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 ( + + + + + + + + + + ); +} + +export default App; diff --git a/src/api/__tests__/auth-api.test.ts b/src/api/__tests__/auth-api.test.ts new file mode 100644 index 0000000..d13d4c1 --- /dev/null +++ b/src/api/__tests__/auth-api.test.ts @@ -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); + } + }); +}); diff --git a/src/api/__tests__/introducer-api.test.ts b/src/api/__tests__/introducer-api.test.ts new file mode 100644 index 0000000..27bf2e6 --- /dev/null +++ b/src/api/__tests__/introducer-api.test.ts @@ -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); + } + }); +}); diff --git a/src/api/__tests__/settle-requests-api.test.ts b/src/api/__tests__/settle-requests-api.test.ts new file mode 100644 index 0000000..d6aff21 --- /dev/null +++ b/src/api/__tests__/settle-requests-api.test.ts @@ -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); + } + }); +}); diff --git a/src/api/__tests__/temp-user-api.test.ts b/src/api/__tests__/temp-user-api.test.ts new file mode 100644 index 0000000..7ce57ac --- /dev/null +++ b/src/api/__tests__/temp-user-api.test.ts @@ -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('کد وارد شده اشتباه است.'); + } + }); +}); diff --git a/src/api/__tests__/user-api.test.ts b/src/api/__tests__/user-api.test.ts new file mode 100644 index 0000000..4240b20 --- /dev/null +++ b/src/api/__tests__/user-api.test.ts @@ -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); + } + }); +}); diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..f52c960 --- /dev/null +++ b/src/api/auth.ts @@ -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 { + const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); + if (accessToken) return accessToken; + return await getFreshToken(); +} + +export async function getFreshToken(): Promise { + 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 { + 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 { + return await request(false, { + method: 'POST', + url: '/auth/refresh', + data: { refresh: token } + }); +} + +export function logoutUser(): Promise { + return Promise.resolve(); +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..77b5440 --- /dev/null +++ b/src/api/index.ts @@ -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; + } + } +} diff --git a/src/api/introducer.ts b/src/api/introducer.ts new file mode 100644 index 0000000..69d2503 --- /dev/null +++ b/src/api/introducer.ts @@ -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 { + return await request(true, { url: '/introducers/introducer' }); +} + +export type CreateIntroducerDataType = { iban: string }; + +export async function createIntroducer(data: CreateIntroducerDataType): Promise { + return await request(true, { + url: '/introducers/introducers', + method: 'POST', + data + }); +} + +export type UpdateIntroducerDataType = { iban: string }; + +export async function updateIntroducer(data: UpdateIntroducerDataType): Promise { + return await request(true, { + url: '/introducers/introducer', + method: 'PATCH', + data + }); +} diff --git a/src/api/settle-request.ts b/src/api/settle-request.ts new file mode 100644 index 0000000..c49f868 --- /dev/null +++ b/src/api/settle-request.ts @@ -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 { + return await request(true, { url: '/introducers/settle-requests' }); +} + +export type CreateSettleRequestDataType = { iban: string }; + +export async function createSettleRequest( + data: CreateSettleRequestDataType +): Promise { + return await request(true, { + url: '/introducers/settle-requests', + method: 'POST', + data + }); +} diff --git a/src/api/temp-user.ts b/src/api/temp-user.ts new file mode 100644 index 0000000..64dfbba --- /dev/null +++ b/src/api/temp-user.ts @@ -0,0 +1,38 @@ +import { request } from './index'; + +export type CreateTempUserDataType = { mobile: string }; +export type TempUserType = CreateTempUserDataType; + +export async function createTempUser(data: CreateTempUserDataType): Promise { + 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 { + 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 { + return await request(false, { + url: '/users/temp-users/check-sms-key', + method: 'PATCH', + data + }); +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..33fcd08 --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,34 @@ +import { request } from './index'; + +export type UserType = { + id: number; + mobile: string; + name: string; + isSuperUser: boolean; +}; + +export async function fetchUser(): Promise { + 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 { + return await request(false, { + url: '/users/users', + method: 'POST', + data + }); +} diff --git a/src/components/default/__tests__/default-page.test.tsx b/src/components/default/__tests__/default-page.test.tsx new file mode 100644 index 0000000..c58fc1d --- /dev/null +++ b/src/components/default/__tests__/default-page.test.tsx @@ -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(, MemoryRouter); + await expectPageTestIds(result); + }); + + test('not redirect to login with not authenticated user', async () => { + mockFailureResponse(); + renderWithClient(, MemoryRouter); + expect(window.location.pathname).not.toBe('/login'); + }); +}); diff --git a/src/components/default/index.tsx b/src/components/default/index.tsx new file mode 100644 index 0000000..30b38f3 --- /dev/null +++ b/src/components/default/index.tsx @@ -0,0 +1,7 @@ +import MainLayout from '../layouts/main'; + +function DefaultPage() { + return ; +} + +export default DefaultPage; diff --git a/src/components/financial/__tests__/financial-container.test.tsx b/src/components/financial/__tests__/financial-container.test.tsx new file mode 100644 index 0000000..6dea29e --- /dev/null +++ b/src/components/financial/__tests__/financial-container.test.tsx @@ -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(); + expect(await result.findByTestId('loader')).toBeInTheDocument(); + }); + + test('successful render data', async () => { + const result = renderWithClient(); + expect(await result.findByTestId('settle-requests')).toBeInTheDocument(); + }); + + test('successful render error data', async () => { + mockFailureResponse(); + const result = renderWithClient(); + expect(await result.findByTestId('error')).toBeInTheDocument(); + }); +}); diff --git a/src/components/financial/__tests__/financial-page.test.tsx b/src/components/financial/__tests__/financial-page.test.tsx new file mode 100644 index 0000000..531aae9 --- /dev/null +++ b/src/components/financial/__tests__/financial-page.test.tsx @@ -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(, MemoryRouter); + await expectPageTitle(FINANCIAL_PAGE_TITLE); + await expectPageTestIds(result); + }); + + test('redirect to login with not authenticated user', async () => { + mockFailureResponse(); + const result = renderWithClient(, createRouterWrapper('/home')); + await expectRedirectedToLoginPage(result); + }); +}); diff --git a/src/components/financial/__tests__/financial-tab.test.tsx b/src/components/financial/__tests__/financial-tab.test.tsx new file mode 100644 index 0000000..5ea2fc5 --- /dev/null +++ b/src/components/financial/__tests__/financial-tab.test.tsx @@ -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(); + expect(await result.findByTestId('financial-tab')).toBeInTheDocument(); + for (const tabName of TAB_NAMES) { + expect(await result.findByText(tabName)).toBeInTheDocument(); + } + }); +}); diff --git a/src/components/financial/container.tsx b/src/components/financial/container.tsx new file mode 100644 index 0000000..3cc8549 --- /dev/null +++ b/src/components/financial/container.tsx @@ -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 ; + if (isError) return ; + if (isSuccess) return ; + return null; +} + +export default FinancialContainer; diff --git a/src/components/financial/financial-tab.tsx b/src/components/financial/financial-tab.tsx new file mode 100644 index 0000000..30e1233 --- /dev/null +++ b/src/components/financial/financial-tab.tsx @@ -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 ( +
+ + + {TAB_NAMES.map(tabName => ( + + 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} + + ))} + + + + + + + + + + +
+ ); +} + +export const TAB_NAMES = ['سوابق مالی', 'درخواست تسویه جدید']; + +export default FinancialTab; diff --git a/src/components/financial/index.tsx b/src/components/financial/index.tsx new file mode 100644 index 0000000..31e9c28 --- /dev/null +++ b/src/components/financial/index.tsx @@ -0,0 +1,14 @@ +import MainLayout from '../layouts/main'; +import FinancialContainer from './container'; + +function FinancialPage() { + return ( + + + + ); +} + +export const FINANCIAL_PAGE_TITLE = 'سوابق مالی'; + +export default FinancialPage; diff --git a/src/components/financial/settle-request/__tests__/create-settle-request-form.test.tsx b/src/components/financial/settle-request/__tests__/create-settle-request-form.test.tsx new file mode 100644 index 0000000..e24d4e0 --- /dev/null +++ b/src/components/financial/settle-request/__tests__/create-settle-request-form.test.tsx @@ -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(, 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(, 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(); + }); +}); diff --git a/src/components/financial/settle-request/__tests__/create-settle-request.test.tsx b/src/components/financial/settle-request/__tests__/create-settle-request.test.tsx new file mode 100644 index 0000000..b36b147 --- /dev/null +++ b/src/components/financial/settle-request/__tests__/create-settle-request.test.tsx @@ -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(); + expect(await result.findByTestId('create-settle-request')).toBeInTheDocument(); + expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument(); + }); +}); diff --git a/src/components/financial/settle-request/__tests__/settle-request-container.test.tsx b/src/components/financial/settle-request/__tests__/settle-request-container.test.tsx new file mode 100644 index 0000000..83e38db --- /dev/null +++ b/src/components/financial/settle-request/__tests__/settle-request-container.test.tsx @@ -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(); + expect(await result.findByTestId('create-settle-request')).toBeInTheDocument(); + expect(await result.findByTestId('create-settle-request-form')).toBeInTheDocument(); + }); +}); diff --git a/src/components/financial/settle-request/__tests__/settle-request.test.tsx b/src/components/financial/settle-request/__tests__/settle-request.test.tsx new file mode 100644 index 0000000..ccf9e84 --- /dev/null +++ b/src/components/financial/settle-request/__tests__/settle-request.test.tsx @@ -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(); + 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(); + expect(await result.findByTestId('settle-request')).toBeInTheDocument(); + expect(await result.findByTestId('pending-settle-request-exists')).toBeInTheDocument(); + }); +}); diff --git a/src/components/financial/settle-request/component.tsx b/src/components/financial/settle-request/component.tsx new file mode 100644 index 0000000..f3faab3 --- /dev/null +++ b/src/components/financial/settle-request/component.tsx @@ -0,0 +1,16 @@ +import CreateSettleRequestForm from './create-settle-request-form'; + +export type CreateSettleRequestPropsType = { iban: string }; + +function CreateSettleRequest({ iban }: CreateSettleRequestPropsType) { + return ( +
+ +
+ ); +} + +export default CreateSettleRequest; diff --git a/src/components/financial/settle-request/container.tsx b/src/components/financial/settle-request/container.tsx new file mode 100644 index 0000000..bfd2b9a --- /dev/null +++ b/src/components/financial/settle-request/container.tsx @@ -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 ; + return null; +} + +export default CreateSettleRequestContainer; diff --git a/src/components/financial/settle-request/create-settle-request-form.tsx b/src/components/financial/settle-request/create-settle-request-form.tsx new file mode 100644 index 0000000..2a04699 --- /dev/null +++ b/src/components/financial/settle-request/create-settle-request-form.tsx @@ -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 ( + + + {() => { + return ( + <> + {formErrorMessage && ( + +

{formErrorMessage}

+

+ برای اطلاعات بیشتر به بخش + + شرایط و قوانین + + مراجعه نمایید. +

+
+ )} + +
+ + + +

+ سهم معرف: + {introducer.code.share} + % +

+

+ تخفیف داوطلب: + {introducer.code.discount} + % +

+

+ مدت اعتبار برای هر داوطلب: + {introducer.code.validDays} + روز +

+

+ تعداد داوطلبان معرفی شده: + {introducer.studentCount} + نفر +

+

+ کل درآمد معرف: + {introducer.sharePrice.toLocaleString()} + ریال +

+

+ دریافتی معرف: + {introducer.paidPrice.toLocaleString()} + ریال +

+
+ ); +} + +export default Introducer; diff --git a/src/components/layouts/main/__tests__/auth-header.test.tsx b/src/components/layouts/main/__tests__/auth-header.test.tsx new file mode 100644 index 0000000..875fa65 --- /dev/null +++ b/src/components/layouts/main/__tests__/auth-header.test.tsx @@ -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(, MemoryRouter); + + expect(await result.findByText(/تست/i)).toBeInTheDocument(); + expect(await result.findByText(/خروج/i)).toBeInTheDocument(); + }); + + test('failure query component', async () => { + mockFailureResponse(); + const result = renderWithClient(, MemoryRouter); + + expect(await result.findByText(/ورود/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/layouts/main/__tests__/footer.test.tsx b/src/components/layouts/main/__tests__/footer.test.tsx new file mode 100644 index 0000000..08d3192 --- /dev/null +++ b/src/components/layouts/main/__tests__/footer.test.tsx @@ -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(