start
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
99
.eslintrc.json
Normal file
99
.eslintrc.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parser": "@babel/eslint-parser",
|
||||||
|
"plugins": [
|
||||||
|
"react",
|
||||||
|
"react-hooks"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"SwitchCase": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"linebreak-style": [
|
||||||
|
"error",
|
||||||
|
"unix"
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"single",
|
||||||
|
{
|
||||||
|
"avoidEscape": true,
|
||||||
|
"allowTemplateLiterals": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"no-console": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"warn",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsx-quotes": [
|
||||||
|
"error",
|
||||||
|
"prefer-single"
|
||||||
|
],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"curly": [
|
||||||
|
"error",
|
||||||
|
"multi-or-nest",
|
||||||
|
"consistent"
|
||||||
|
],
|
||||||
|
"object-curly-spacing": [
|
||||||
|
"error",
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"multiline-ternary": "off",
|
||||||
|
"operator-linebreak": [
|
||||||
|
"error",
|
||||||
|
"after"
|
||||||
|
],
|
||||||
|
"no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignoreRestSiblings": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lines-between-class-members": [
|
||||||
|
"error",
|
||||||
|
"always",
|
||||||
|
{
|
||||||
|
"exceptAfterSingleLine": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Local Claude Code config — settings.local.json holds per-machine permissions.
|
||||||
|
.claude/
|
||||||
13
.prettierrc.json
Normal file
13
.prettierrc.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
77
package.json
Normal file
77
package.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "bi_panel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^1.0.5",
|
||||||
|
"@testing-library/jest-dom": "^5.16.1",
|
||||||
|
"@testing-library/react": "^12.1.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"axios": "^0.24.0",
|
||||||
|
"chart.js": "^3.7.1",
|
||||||
|
"chartjs-adapter-date-fns": "^2.0.0",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
|
"date-fns": "^2.28.0",
|
||||||
|
"date-fns-jalali": "^2.28.0-0",
|
||||||
|
"formik": "^2.2.9",
|
||||||
|
"moment-jalaali": "^0.9.4",
|
||||||
|
"prop-types": "^15.8.0",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-chartjs-2": "^4.0.1",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-query": "^3.34.7",
|
||||||
|
"react-redux": "^7.2.6",
|
||||||
|
"react-router-dom": "^5.3.0",
|
||||||
|
"react-scripts": "^5.0.0",
|
||||||
|
"react-toast-notifications": "^2.5.1",
|
||||||
|
"redux": "^4.1.2",
|
||||||
|
"redux-thunk": "^2.4.1",
|
||||||
|
"web-vitals": "^2.1.2"
|
||||||
|
},
|
||||||
|
"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.16.5",
|
||||||
|
"@tailwindcss/forms": "^0.4.0",
|
||||||
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.6",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"enzyme": "^3.11.0",
|
||||||
|
"enzyme-to-json": "^3.6.2",
|
||||||
|
"eslint": "^8.5.0",
|
||||||
|
"eslint-config-standard": "^16.0.3",
|
||||||
|
"eslint-plugin-import": "^2.25.3",
|
||||||
|
"eslint-plugin-jest": "^25.3.2",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-promise": "^6.0.0",
|
||||||
|
"eslint-plugin-react": "^7.28.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
|
"faker": "^5.5.3",
|
||||||
|
"postcss": "^8.4.5",
|
||||||
|
"react-test-renderer": "^17.0.2",
|
||||||
|
"redux-devtools-extension": "^2.13.9",
|
||||||
|
"tailwindcss": "^3.0.8",
|
||||||
|
"tailwindcss-rtl": "^0.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
78
public/assets/css/fontiran.css
Normal file
78
public/assets/css/fontiran.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
*
|
||||||
|
* Name: IRAN Sans-Serif Font
|
||||||
|
* Version: 5.0
|
||||||
|
* Author: Moslem Ebrahimi (moslemebrahimi.com)
|
||||||
|
* Created on: Dec 25, 2012
|
||||||
|
* Updated on: Sep 01, 2017
|
||||||
|
* Website: http://fontiran.com
|
||||||
|
* Copyright: Commercial/Proprietary Software
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
فونت های ایران سن سریف یک نرم افزار مالکیتی محسوب می شود. جهت آگاهی از قوانین استفاده از این فونت ها لطفا به وب سایت (فونت ایران دات کام) مراجعه نمایید
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
IRAN Sans-serif fonts are considered a proprietary software. To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
This set of fonts are used in this project under the license: (GY3QV)
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
**/
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Black.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Black.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: bold;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_Light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_Light.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANSans;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum).eot');
|
||||||
|
src: url('../fonts/eot/IRANSansWeb(FaNum).eot?#iefix') format('embedded-opentype'), /* IE6-8 */
|
||||||
|
url('../fonts/woff2/IRANSansWeb(FaNum).woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
|
||||||
|
url('../fonts/woff/IRANSansWeb(FaNum).woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
|
||||||
|
url('../fonts/ttf/IRANSansWeb(FaNum).ttf') format('truetype');
|
||||||
|
}
|
||||||
66
public/assets/css/style.css
Normal file
66
public/assets/css/style.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
1
public/assets/css/style.min.css
vendored
Normal file
1
public/assets/css/style.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import url(./fontiran.css);@keyframes progressBar{0%,50.01%{width:0;background-color:transparent}49.99%{width:100%;background-color:#00b1da}to{width:100%;background-color:#fb8928}}body{font-weight:300;direction:rtl;margin:0}a,body,button,div,h1,h2,h3,h4,h5,h6,input,label,span,strong,text,textarea{font-family:IRANSans!important}.preloader-container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column}.meter{height:10px;width:172px;background:#f3efe6;direction:ltr;border-radius:5px;margin:12px 0}.progress{height:100%;display:block;border-radius:5px;animation:progressBar 1.2s ease-in-out infinite;animation-fill-mode:both}.leading-0{line-height:0}
|
||||||
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum).eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum).eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot
Normal file
BIN
public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf
Normal file
BIN
public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum).woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum).woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff
Normal file
BIN
public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2
Normal file
BIN
public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2
Normal file
Binary file not shown.
12
public/browserconfig.xml
Normal file
12
public/browserconfig.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square70x70logo src="https://medmoshaver.com/icons/ms/icon-70.png"/>
|
||||||
|
<square144x144logo src="https://medmoshaver.com/icons/ms/icon-144.png"/>
|
||||||
|
<square150x150logo src="https://medmoshaver.com/icons/ms/icon-150.png"/>
|
||||||
|
<square310x310logo src="https://medmoshaver.com/icons/ms/icon-310.png"/>
|
||||||
|
<TileColor>#ffffff</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
157
public/index.html
Normal file
157
public/index.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang='fa'>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8' />
|
||||||
|
<meta content='IE=edge' http-equiv='X-UA-Compatible'>
|
||||||
|
<meta
|
||||||
|
content='width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no'
|
||||||
|
name='viewport'
|
||||||
|
>
|
||||||
|
<meta title='پنل هوش تجاری فلش کارت های گروه مدمشاور'>
|
||||||
|
<meta
|
||||||
|
content='گروه مد مشاور متشکل از تعدادی از رتبههای برتر آزمون دستیاری میباشد
|
||||||
|
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه میدهد.'
|
||||||
|
name='description'
|
||||||
|
>
|
||||||
|
<title>پنل هوش تجاری فلش کارت های گروه مد مشاور</title>
|
||||||
|
|
||||||
|
<!-- Android -->
|
||||||
|
<meta content='#fafafa' name='theme-color'>
|
||||||
|
<meta content='yes' name='mobile-web-app-capable'>
|
||||||
|
|
||||||
|
<!-- iOS -->
|
||||||
|
<meta content='پنل هوش تجاری فلش کارت های گروه مد مشاور' name='apple-mobile-web-app-title'>
|
||||||
|
<meta content='yes' name='apple-mobile-web-app-capable'>
|
||||||
|
<meta content='default' name='apple-mobile-web-app-status-bar-style'>
|
||||||
|
|
||||||
|
<!-- Windows -->
|
||||||
|
<meta content='#fafafa' name='msapplication-navbutton-color'>
|
||||||
|
<meta content='#fafafa' name='msapplication-TileColor'>
|
||||||
|
<meta content='https://medmoshaver.com/icons/ms/icon-144.png' name='msapplication-TileImage'>
|
||||||
|
<meta content='%PUBLIC_URL%/browserconfig.xml' name='msapplication-config'>
|
||||||
|
|
||||||
|
<!-- Pinned Sites -->
|
||||||
|
<meta content='Application Name' name='application-name'>
|
||||||
|
<meta content='Tooltip Text' name='msapplication-tooltip'>
|
||||||
|
<meta content='/' name='msapplication-starturl'>
|
||||||
|
|
||||||
|
<!-- Tap highlighting -->
|
||||||
|
<meta content='no' name='msapplication-tap-highlight'>
|
||||||
|
|
||||||
|
<!-- UC Mobile Browser -->
|
||||||
|
<meta content='yes' name='full-screen'>
|
||||||
|
<meta content='application' name='browsermode'>
|
||||||
|
|
||||||
|
<!-- Disable night mode for this page -->
|
||||||
|
<meta content='enable/disable' name='nightmode'>
|
||||||
|
|
||||||
|
<!-- Fitscreen -->
|
||||||
|
<!-- <meta name="viewport" content="uc-fitscreen=yes"/>-->
|
||||||
|
|
||||||
|
<!-- Layout mode -->
|
||||||
|
<meta content='fitscreen/standard' name='layoutmode'>
|
||||||
|
|
||||||
|
<!-- imagemode - show image even in text only mode -->
|
||||||
|
<meta content='force' name='imagemode'>
|
||||||
|
|
||||||
|
<!-- Orientation -->
|
||||||
|
<meta content='portrait' name='screen-orientation'>
|
||||||
|
|
||||||
|
<!-- open graph markup -->
|
||||||
|
<meta content='website' property='og:type' />
|
||||||
|
<meta content='Med Moshaver' property='og:site_name' />
|
||||||
|
<meta content='https://flashcard.medmoshaver.com/bi' property='og:url' />
|
||||||
|
<meta content='مد مشاور - مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی'
|
||||||
|
property='og:title' />
|
||||||
|
<meta content='https://medmoshaver.com/icons/open-graph.png' property='og:image' />
|
||||||
|
<meta content='image/png' property='og:image:type' />
|
||||||
|
<meta content='1200' property='og:image:width' />
|
||||||
|
<meta content='630' property='og:image:height' />
|
||||||
|
<meta
|
||||||
|
content='گروه مد مشاور متشکل از تعدادی از رتبههای برتر آزمون دستیاری میباشد
|
||||||
|
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه میدهد.'
|
||||||
|
property='og:description'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Link Tags -->
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-16.png' rel='icon' sizes='16x16'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-32.png' rel='icon' sizes='32x32'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-48.png' rel='icon' sizes='48x48'
|
||||||
|
type='image/png'>
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon-96.png' rel='icon' sizes='96x96'
|
||||||
|
type='image/png'>
|
||||||
|
|
||||||
|
<!-- iOS -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon.png' rel='apple-touch-icon'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-76.png' rel='apple-touch-icon' sizes='76x76'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-120.png' rel='apple-touch-icon'
|
||||||
|
sizes='120x120'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-152.png' rel='apple-touch-icon'
|
||||||
|
sizes='152x152'>
|
||||||
|
|
||||||
|
<!-- Startup Image -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-320x480.png' rel='apple-touch-startup-image'>
|
||||||
|
|
||||||
|
<!-- Pinned Tab -->
|
||||||
|
<link color='#fafafa' href='https://medmoshaver.com/icons/icon.svg' rel='mask-icon' size='any'>
|
||||||
|
|
||||||
|
<!-- Android -->
|
||||||
|
<link href='https://medmoshaver.com/icons/android/icon-192.png' rel='icon' sizes='192x192'>
|
||||||
|
<link href='https://medmoshaver.com/icons/android/icon-128.png' rel='icon' sizes='128x128'>
|
||||||
|
|
||||||
|
<!-- Others -->
|
||||||
|
<link href='https://medmoshaver.com/icons/favicon/favicon.ico' rel='shortcut icon'
|
||||||
|
type='image/x-icon'>
|
||||||
|
|
||||||
|
<!-- UC Browser -->
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-57.png' rel='apple-touch-icon-precomposed'
|
||||||
|
sizes='57x57'>
|
||||||
|
<link href='https://medmoshaver.com/icons/iOS/icon-72.png' rel='apple-touch-icon' sizes='72x72'>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link href='%PUBLIC_URL%/manifest.json' rel='manifest' />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<link href='%PUBLIC_URL%/assets/css/style.min.css' rel='stylesheet' />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body dir='rtl'>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id='root'>
|
||||||
|
<div class='preloader-container'>
|
||||||
|
<img
|
||||||
|
alt='logo'
|
||||||
|
class='preloader-content'
|
||||||
|
src='https://medmoshaver.com/icons/icon.svg'
|
||||||
|
width='168px'
|
||||||
|
height='168px'
|
||||||
|
/>
|
||||||
|
<div class='meter'>
|
||||||
|
<span class='progress'></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
public/lib/.gitkeep
Normal file
0
public/lib/.gitkeep
Normal file
83
public/manifest.json
Normal file
83
public/manifest.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Medmoshaver FlashCard BI Panel",
|
||||||
|
"name": "Medmoshaver FlashCard BI 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"
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
0
src/App.css
Normal file
0
src/App.css
Normal file
26
src/App.js
Normal file
26
src/App.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {BrowserRouter as Router, Route, Switch} from 'react-router-dom';
|
||||||
|
import moment from 'moment-jalaali';
|
||||||
|
import HomePage from './components/home';
|
||||||
|
import DefaultPage from './components/default';
|
||||||
|
import LoginPage from './components/login';
|
||||||
|
import OrdersPage from './components/orders';
|
||||||
|
import StudentsCountPage from './components/students-count';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router basename={process.env.PUBLIC_URL}>
|
||||||
|
<Switch>
|
||||||
|
<Route exact path='/' component={HomePage} />
|
||||||
|
<Route exact path='/login' component={LoginPage} />
|
||||||
|
<Route exact path='/orders' component={OrdersPage} />
|
||||||
|
<Route exact path='/students-count' component={StudentsCountPage} />
|
||||||
|
<Route path='*' component={DefaultPage} />
|
||||||
|
</Switch>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
moment.loadPersian({usePersianDigits: false, dialect: 'persian-modern'});
|
||||||
|
|
||||||
|
export default App;
|
||||||
0
src/App.min.css
vendored
Normal file
0
src/App.min.css
vendored
Normal file
42
src/api/auth.js
Normal file
42
src/api/auth.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {request} from './index';
|
||||||
|
|
||||||
|
// local storage keys
|
||||||
|
export const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
|
export const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
|
||||||
|
export async function getToken() {
|
||||||
|
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
if (accessToken) return accessToken;
|
||||||
|
return await getFreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreshToken() {
|
||||||
|
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 async function fetchToken({mobile, password}) {
|
||||||
|
return await request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/login',
|
||||||
|
data: {mobile, password}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFreshToken(token) {
|
||||||
|
return await request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/refresh',
|
||||||
|
data: {refresh: token}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutUser(queryClient, history = null) {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
queryClient.invalidateQueries('user');
|
||||||
|
if (history) history.push('/login');
|
||||||
|
}
|
||||||
28
src/api/index.js
Normal file
28
src/api/index.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import {getFreshToken, getToken} from './auth';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: process.env.REACT_APP_API_BASE_URL,
|
||||||
|
headers: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function request(options) {
|
||||||
|
const {auth, ...requestOptions} = options;
|
||||||
|
if (auth) {
|
||||||
|
const accessToken = await getToken();
|
||||||
|
requestOptions.headers = {...requestOptions.headers, Authorization: `Bearer ${accessToken}`};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await apiClient(requestOptions)).data;
|
||||||
|
} catch (error) {
|
||||||
|
if (!auth || error.response?.status !== 401) throw error.response;
|
||||||
|
const accessToken = await getFreshToken();
|
||||||
|
requestOptions.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
try {
|
||||||
|
return (await apiClient(requestOptions)).data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/api/order.js
Normal file
5
src/api/order.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {request} from './index';
|
||||||
|
|
||||||
|
export async function fetchOrderData() {
|
||||||
|
return await request({url: '/decks/ordered-decks/data', auth: true});
|
||||||
|
}
|
||||||
5
src/api/student.js
Normal file
5
src/api/student.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {request} from './index';
|
||||||
|
|
||||||
|
export async function fetchStudentsCountData() {
|
||||||
|
return await request({url: '/students/students-count-data', auth: true});
|
||||||
|
}
|
||||||
5
src/api/user.js
Normal file
5
src/api/user.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {request} from './index';
|
||||||
|
|
||||||
|
export async function fetchUser() {
|
||||||
|
return await request({url: '/users/user', auth: true});
|
||||||
|
}
|
||||||
9
src/components/default/index.js
Normal file
9
src/components/default/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
|
||||||
|
function DefaultPage() {
|
||||||
|
return (
|
||||||
|
<MainLayout loginRequired={false}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DefaultPage;
|
||||||
12
src/components/home/home.js
Normal file
12
src/components/home/home.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Segment from '../shared/segment';
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
return (
|
||||||
|
<Segment>
|
||||||
|
<p className='text-large'>پنل هوش تجاری فلش کارت های گروه مد مشاور</p>
|
||||||
|
<p>به پنل هوش تجاری فلش کارت های گروه مدمشاور خوش آمدید.</p>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
16
src/components/home/index.js
Normal file
16
src/components/home/index.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import Home from './home';
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<MainLayout
|
||||||
|
loginRequired={false}
|
||||||
|
consultPackageRequired={false}
|
||||||
|
packageRequired={false}
|
||||||
|
>
|
||||||
|
<Home/>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
53
src/components/layouts/main/auth-header.js
Normal file
53
src/components/layouts/main/auth-header.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {useHistory} from 'react-router-dom';
|
||||||
|
import {useQueryClient} from 'react-query';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import NavLink from '../../shared/buttons/nav-link';
|
||||||
|
import ButtonGroup from '../../shared/buttons/button-group';
|
||||||
|
import Button from '../../shared/buttons/button';
|
||||||
|
import {useUser} from '../../../hooks/user';
|
||||||
|
import {logoutUser} from '../../../api/auth';
|
||||||
|
|
||||||
|
function AuthHeader(props) {
|
||||||
|
const history = useHistory();
|
||||||
|
const {status, data: user} = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const nextURL = `${history.location.pathname}${history.location.search.replace('&', ';')}`;
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<ButtonGroup className={props.className}>
|
||||||
|
<NavLink
|
||||||
|
to={`/login?next=${nextURL}`}
|
||||||
|
color='blue'
|
||||||
|
content='ورود'
|
||||||
|
className={props.className}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup className={props.className}>
|
||||||
|
<NavLink
|
||||||
|
to='/profile'
|
||||||
|
content={user?.name || user?.mobile}
|
||||||
|
loading={status === 'loading'}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
content='خروج'
|
||||||
|
color='red'
|
||||||
|
onClick={() => logoutUser(queryClient, history)}
|
||||||
|
loading={status === 'loading'}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthHeader.propTypes = {
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthHeader;
|
||||||
7
src/components/layouts/main/footer.js
Normal file
7
src/components/layouts/main/footer.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
function Footer() {
|
||||||
|
return (
|
||||||
|
<footer/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
78
src/components/layouts/main/header.js
Normal file
78
src/components/layouts/main/header.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const [isHidden, setIsHidden] = useState(true);
|
||||||
|
const iconSize = 34;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className='sticky top-0 z-50'>
|
||||||
|
<nav className='flex flex-wrap items-center justify-between bg-gray-800 p-2'>
|
||||||
|
<NavLink to='/' color='transparent' className='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-blue-500 stroke-current p-1 text-blue-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<XIcon
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
className={classNames(
|
||||||
|
'toggle block',
|
||||||
|
{hidden: isHidden},
|
||||||
|
'rounded-md border-2 border-blue-500 stroke-current p-1 text-blue-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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.map(link => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className={`
|
||||||
|
w-100 block rounded-none border-t-2 border-blue-500
|
||||||
|
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: '/'},
|
||||||
|
{id: 2, name: 'فروش', href: '/orders'}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default Header;
|
||||||
69
src/components/layouts/main/index.js
Normal file
69
src/components/layouts/main/index.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {Component, Fragment} from 'react';
|
||||||
|
import {withRouter, Redirect} from 'react-router-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {UseUser} from '../../shared/use-user';
|
||||||
|
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';
|
||||||
|
|
||||||
|
class MainLayout extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
const {title, description} = this.props;
|
||||||
|
if (title) document.title = title;
|
||||||
|
if (description) document.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent = status => {
|
||||||
|
const {loginRequired} = this.props;
|
||||||
|
|
||||||
|
if (loginRequired && status === 'loading') return <div><Loader/></div>;
|
||||||
|
if (!loginRequired || status === 'success') return this.props.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {location, loginRequired} = this.props;
|
||||||
|
const currentURL = `${location.pathname}${location.search.replace('&', ';')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UseUser>
|
||||||
|
{({status}) => {
|
||||||
|
if (loginRequired && status === 'error') return <Redirect to={`/login?next=${currentURL}`}/>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className='flex flex-col h-screen'>
|
||||||
|
<Header/>
|
||||||
|
<main className='flex-1 py-4'>
|
||||||
|
<Container>
|
||||||
|
{this.renderContent(status)}
|
||||||
|
</Container>
|
||||||
|
</main>
|
||||||
|
<Footer/>
|
||||||
|
</div>
|
||||||
|
<ScrollToTop/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</UseUser>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MainLayout.propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
description: PropTypes.string,
|
||||||
|
loginRequired: PropTypes.bool.isRequired,
|
||||||
|
location: PropTypes.object.isRequired,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
MainLayout.defaultProps = {
|
||||||
|
loginRequired: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withRouter(MainLayout);
|
||||||
64
src/components/login/form.js
Normal file
64
src/components/login/form.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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/auth';
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const {mutate} = useLoginMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment>
|
||||||
|
<Formik
|
||||||
|
initialValues={{mobile: '', password: ''}}
|
||||||
|
onSubmit={submit.bind(null, mutate)}
|
||||||
|
>
|
||||||
|
{({errors, dirty}) => (
|
||||||
|
<Fragment>
|
||||||
|
{(dirty && errors._error) && (
|
||||||
|
<Alert content={errors._error} className='mt-3 mb-5'/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form className='grid grid-cols-1 gap-5 p-3'>
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
name='mobile'
|
||||||
|
label='شماره تلفن همراه'
|
||||||
|
type='tel'
|
||||||
|
placeholder='09121234567'
|
||||||
|
component={FormInput}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(loginUser, data, {setErrors}) {
|
||||||
|
try {
|
||||||
|
await loginUser(data);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.message || 'شماره تلفن یا رمز عبور نادرست است.';
|
||||||
|
setErrors({_error: errorMessage});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
27
src/components/login/index.js
Normal file
27
src/components/login/index.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {Redirect} from 'react-router-dom';
|
||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import LoginForm from './form';
|
||||||
|
import useQueryParams from '../../hooks/query-params';
|
||||||
|
import {useUser} from '../../hooks/user';
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const {isAuthenticated} = useUser();
|
||||||
|
const {next} = useQueryParams();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const nextURL = next ? next.replace(';', '&') : '/orders';
|
||||||
|
return (<Redirect to={nextURL}/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout loginRequired={false}>
|
||||||
|
<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 default LoginPage;
|
||||||
14
src/components/orders/container.js
Normal file
14
src/components/orders/container.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Orders from './orders';
|
||||||
|
import {useOrdersData} from '../../hooks/order';
|
||||||
|
import Loader from '../shared/loader';
|
||||||
|
import Error from '../shared/error';
|
||||||
|
|
||||||
|
function OrdersContainer() {
|
||||||
|
const {isLoading, isError, data, error} = useOrdersData();
|
||||||
|
|
||||||
|
if (isLoading) return <Loader />;
|
||||||
|
if (isError) return <Error error={error} />;
|
||||||
|
return <Orders orderItems={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrdersContainer;
|
||||||
12
src/components/orders/index.js
Normal file
12
src/components/orders/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import OrdersContainer from './container';
|
||||||
|
|
||||||
|
function OrdersPage() {
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<OrdersContainer/>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrdersPage;
|
||||||
197
src/components/orders/orders.js
Normal file
197
src/components/orders/orders.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import moment from 'moment-jalaali';
|
||||||
|
|
||||||
|
class Orders extends Component {
|
||||||
|
state = {
|
||||||
|
showCount: true,
|
||||||
|
showTotalPrice: true,
|
||||||
|
showAuthorPrice: false,
|
||||||
|
showUnits: true
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleShowCount = () => this.setState(state => ({showCount: !state.showCount}));
|
||||||
|
toggleShowTotalPrice = () => this.setState(state => ({showTotalPrice: !state.showTotalPrice}));
|
||||||
|
toggleShowAuthorPrice = () => this.setState(state => ({showAuthorPrice: !state.showAuthorPrice}));
|
||||||
|
toggleShowUnits = () => this.setState(state => ({showUnits: !state.showUnits}));
|
||||||
|
|
||||||
|
presentPrice = price => (this.state.showUnits ? `${price.toLocaleString()} ریال` : price);
|
||||||
|
presentCount = count => (this.state.showUnits ? `${count} عدد` : count);
|
||||||
|
|
||||||
|
getMonthkeys = () => {
|
||||||
|
const keys = [];
|
||||||
|
const today = moment();
|
||||||
|
let month = today.jMonth() + 1;
|
||||||
|
let year = today.jYear();
|
||||||
|
while (year > 1400 || month > 5) {
|
||||||
|
keys.push(`${year}-${month < 10 ? `0${month}` : month}`);
|
||||||
|
if (month > 1) {
|
||||||
|
month = month - 1;
|
||||||
|
} else {
|
||||||
|
month = 12;
|
||||||
|
year = year - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {showCount, showTotalPrice, showAuthorPrice, showUnits} = this.state;
|
||||||
|
const monthKeys = this.getMonthkeys();
|
||||||
|
|
||||||
|
const dataByOrderItem = {};
|
||||||
|
for (const orderItem of this.props.orderItems) {
|
||||||
|
let price = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const monthKey in orderItem.data) {
|
||||||
|
if (monthKeys.includes(monthKey)) {
|
||||||
|
price += orderItem.data[monthKey].totalPrice;
|
||||||
|
count += orderItem.data[monthKey].count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataByOrderItem[orderItem.id] = {price, count};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataByMonthKey = {};
|
||||||
|
for (const monthKey of monthKeys) dataByMonthKey[monthKey] = {price: 0, count: 0};
|
||||||
|
for (const orderItem of this.props.orderItems) {
|
||||||
|
for (const monthKey in orderItem.data) {
|
||||||
|
if (monthKeys.includes(monthKey)) {
|
||||||
|
dataByMonthKey[monthKey].price += orderItem.data[monthKey]?.totalPrice || 0;
|
||||||
|
dataByMonthKey[monthKey].count += orderItem.data[monthKey]?.count || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalData = {price: 0, count: 0};
|
||||||
|
for (const orderItemId in dataByOrderItem) {
|
||||||
|
totalData.price += dataByOrderItem[orderItemId].price;
|
||||||
|
totalData.count += dataByOrderItem[orderItemId].count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='px-4 overflow-x-auto'>
|
||||||
|
<p className='font-medium text-lg'>جدول فروش ماهیانه:</p>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap justify-center gap-8 my-2'>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='show-total-price'
|
||||||
|
checked={showTotalPrice}
|
||||||
|
onChange={this.toggleShowTotalPrice}
|
||||||
|
/>
|
||||||
|
<label htmlFor='show-total-price' className='mx-2'>
|
||||||
|
نمایش مبلغ کل
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='show-count'
|
||||||
|
checked={showCount}
|
||||||
|
onChange={this.toggleShowCount}
|
||||||
|
/>
|
||||||
|
<label htmlFor='show-count' className='mx-2'>
|
||||||
|
نمایش تعداد
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='show-author-price'
|
||||||
|
checked={showAuthorPrice}
|
||||||
|
onChange={this.toggleShowAuthorPrice}
|
||||||
|
/>
|
||||||
|
<label htmlFor='show-author-price' className='mx-2'>
|
||||||
|
نمایش سهم مولف
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='show-units'
|
||||||
|
checked={showUnits}
|
||||||
|
onChange={this.toggleShowUnits}
|
||||||
|
/>
|
||||||
|
<label htmlFor='show-units' className='mx-2'>
|
||||||
|
نمایش واحدها
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className='my-4 mx-auto'>
|
||||||
|
<thead className='border-collapse border border-gray-700'>
|
||||||
|
<tr className='text-center font-medium bg-indigo-50'>
|
||||||
|
<td className='border border-gray-700 p-3'>#</td>
|
||||||
|
{monthKeys.map(orderPaid => (
|
||||||
|
<td key={orderPaid} className='border border-gray-700 p-3'>
|
||||||
|
{orderPaid}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className='border border-gray-700 bg-indigo-300 p-3'>مجموع</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{this.props.orderItems.map(orderItem => (
|
||||||
|
<tr key={orderItem.id} className='text-center'>
|
||||||
|
<td className='border border-gray-700 bg-indigo-50 font-medium p-3'>
|
||||||
|
{orderItem.name}
|
||||||
|
</td>
|
||||||
|
{monthKeys.map(monthKey => {
|
||||||
|
const orderPaidData = orderItem.data[monthKey];
|
||||||
|
return (
|
||||||
|
<td key={monthKey} className='border border-gray-700 p-3'>
|
||||||
|
{showTotalPrice && <p>{this.presentPrice(orderPaidData?.totalPrice || 0)}</p>}
|
||||||
|
{showCount && <p>{this.presentCount(orderPaidData?.count || 0)}</p>}
|
||||||
|
{showAuthorPrice && (
|
||||||
|
<p>{this.presentPrice((orderPaidData?.totalPrice || 0) / 5)}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className='border border-gray-700 bg-indigo-50 p-3'>
|
||||||
|
{showTotalPrice && (
|
||||||
|
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price)}</p>
|
||||||
|
)}
|
||||||
|
{showCount && <p>{this.presentCount(dataByOrderItem[orderItem.id].count)}</p>}
|
||||||
|
{showAuthorPrice && (
|
||||||
|
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price / 5)}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className='text-center bg-indigo-100'>
|
||||||
|
<td className='border border-green-700 bg-indigo-300 font-medium p-3'>مجموع</td>
|
||||||
|
{monthKeys.map(monthKey => {
|
||||||
|
return (
|
||||||
|
<td key={monthKey} className='border border-gray-700 p-3'>
|
||||||
|
{showTotalPrice && <p>{this.presentPrice(dataByMonthKey[monthKey].price)}</p>}
|
||||||
|
{showCount && <p>{this.presentCount(dataByMonthKey[monthKey].count)}</p>}
|
||||||
|
{showAuthorPrice && (
|
||||||
|
<p>{this.presentPrice(dataByMonthKey[monthKey].price / 5)}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className='border border-gray-700 p-3 bg-indigo-300'>
|
||||||
|
{showTotalPrice && <p>{this.presentPrice(totalData.price)}</p>}
|
||||||
|
{showCount && <p>{this.presentPrice(totalData.count)}</p>}
|
||||||
|
{showAuthorPrice && <p>{this.presentPrice(totalData.price / 5)}</p>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Orders.propTypes = {
|
||||||
|
orderItems: PropTypes.array.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Orders;
|
||||||
24
src/components/shared/alert.js
Normal file
24
src/components/shared/alert.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
function Alert(props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role='alert'
|
||||||
|
className={classNames(
|
||||||
|
'text-center text-lg font-medium',
|
||||||
|
'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative',
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.propTypes = {
|
||||||
|
content: PropTypes.node.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Alert;
|
||||||
50
src/components/shared/buttons/back.js
Normal file
50
src/components/shared/buttons/back.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {useHistory} from 'react-router-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Button from './button';
|
||||||
|
|
||||||
|
function BackLink(props) {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
color={props.color}
|
||||||
|
outline={props.outline}
|
||||||
|
flat={props.flat}
|
||||||
|
size={props.size}
|
||||||
|
loading={props.loading}
|
||||||
|
disabled={props.disabled}
|
||||||
|
className={props.className}
|
||||||
|
onClick={history.goBack}
|
||||||
|
>
|
||||||
|
{props.content || props.children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BackLink.defaultProps = {
|
||||||
|
outline: false,
|
||||||
|
flat: false,
|
||||||
|
size: 'base',
|
||||||
|
textSize: 'sm',
|
||||||
|
color: 'blue',
|
||||||
|
loading: false,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
BackLink.propTypes = {
|
||||||
|
content: PropTypes.string,
|
||||||
|
color: PropTypes.string,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
outline: PropTypes.bool.isRequired,
|
||||||
|
flat: PropTypes.bool.isRequired,
|
||||||
|
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackLink;
|
||||||
25
src/components/shared/buttons/button-group.css
Normal file
25
src/components/shared/buttons/button-group.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
div.btn-group.btn-group-stretched a,
|
||||||
|
div.btn-group.btn-group-stretched button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.btn-group a,
|
||||||
|
div.btn-group button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.btn-group button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.btn-group > button:first-child,
|
||||||
|
div.btn-group > a:first-child > button:first-child {
|
||||||
|
border-top-right-radius: .5rem;
|
||||||
|
border-bottom-right-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.btn-group > button:last-child,
|
||||||
|
div.btn-group > a:last-child > button:last-child {
|
||||||
|
border-top-left-radius: .5rem;
|
||||||
|
border-bottom-left-radius: .5rem;
|
||||||
|
}
|
||||||
29
src/components/shared/buttons/button-group.js
Normal file
29
src/components/shared/buttons/button-group.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import './button-group.css';
|
||||||
|
|
||||||
|
function ButtonGroup(props) {
|
||||||
|
return (
|
||||||
|
<div className={classNames(
|
||||||
|
'flex btn-group',
|
||||||
|
{'btn-group-stretched': props.float},
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonGroup.defaultProps = {float: false};
|
||||||
|
|
||||||
|
ButtonGroup.propTypes = {
|
||||||
|
float: PropTypes.bool.isRequired,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ButtonGroup;
|
||||||
1
src/components/shared/buttons/button-group.min.css
vendored
Normal file
1
src/components/shared/buttons/button-group.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
div.btn-group{margin:.5rem}div.btn-group>button{margin:0;border-radius:0}div.btn-group>button:first-child{border-top-right-radius:.5rem;border-bottom-right-radius:.5rem}div.btn-group>button:last-child{border-top-left-radius:.5rem;border-bottom-left-radius:.5rem}
|
||||||
120
src/components/shared/buttons/button.js
Normal file
120
src/components/shared/buttons/button.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import FilledButton from './filled-button';
|
||||||
|
import FlatButton from './flat-button';
|
||||||
|
import OutlineButton from './outline-button';
|
||||||
|
|
||||||
|
class Button extends Component {
|
||||||
|
getSizeClass = () => {
|
||||||
|
const sizePaddingMap = {
|
||||||
|
default: {
|
||||||
|
zero: `p-0`,
|
||||||
|
xs: 'py-0.5 px-1',
|
||||||
|
sm: 'py-1 px-2',
|
||||||
|
base: 'py-1.5 px-3',
|
||||||
|
lg: 'py-2 px-4',
|
||||||
|
xl: 'py-2.5 px-5'
|
||||||
|
},
|
||||||
|
circular: {
|
||||||
|
zero: `p-0`,
|
||||||
|
xs: 'p-0.5',
|
||||||
|
sm: 'p-1',
|
||||||
|
base: 'p-1.5',
|
||||||
|
lg: 'p-2',
|
||||||
|
xl: 'p-2.5'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const {size, textSize, circular} = this.props;
|
||||||
|
return `text-${textSize} ${sizePaddingMap[circular ? 'circular' : 'default'][size]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
getContainer = () => {
|
||||||
|
if (this.props.flat) return FlatButton;
|
||||||
|
if (this.props.outline) return OutlineButton;
|
||||||
|
return FilledButton;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const Container = this.getContainer();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
{...this.props}
|
||||||
|
className={classNames(
|
||||||
|
this.getSizeClass(),
|
||||||
|
this.props.inline ? 'inline-flex' : 'flex',
|
||||||
|
`justify-${this.props.justifyContent} items-center`,
|
||||||
|
this.props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(this.props.content !== undefined) ? this.props.content : this.props.children}
|
||||||
|
{this.props.loading && (
|
||||||
|
<svg
|
||||||
|
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
fill='none'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className='opacity-25'
|
||||||
|
cx='12'
|
||||||
|
cy='12'
|
||||||
|
r='10'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='4'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className='opacity-75'
|
||||||
|
fill='currentColor'
|
||||||
|
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button.defaultProps = {
|
||||||
|
type: 'button',
|
||||||
|
inline: false,
|
||||||
|
outline: false,
|
||||||
|
flat: false,
|
||||||
|
size: 'base',
|
||||||
|
textSize: 'sm',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'blue',
|
||||||
|
colorWeight: 500,
|
||||||
|
borderWeight: 1,
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
circular: false
|
||||||
|
};
|
||||||
|
|
||||||
|
Button.propTypes = {
|
||||||
|
content: PropTypes.string,
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
|
colorWeight: PropTypes.number.isRequired,
|
||||||
|
borderWeight: PropTypes.number.isRequired,
|
||||||
|
textColor: PropTypes.string,
|
||||||
|
textColorWeight: PropTypes.string,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
inline: PropTypes.bool.isRequired,
|
||||||
|
outline: PropTypes.bool.isRequired,
|
||||||
|
flat: PropTypes.bool.isRequired,
|
||||||
|
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
justifyContent: PropTypes.oneOf(['start', 'center', 'end', 'between', 'around', 'evenly']).isRequired,
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
circular: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
64
src/components/shared/buttons/filled-button.js
Normal file
64
src/components/shared/buttons/filled-button.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import {withoutWeightColor} from './utils';
|
||||||
|
|
||||||
|
class FilledButton extends Component {
|
||||||
|
getColorClass = () => {
|
||||||
|
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
|
||||||
|
|
||||||
|
return classNames(
|
||||||
|
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||||
|
{'text-white': !textColor},
|
||||||
|
{'opacity-40 cursor-not-allowed': disabled},
|
||||||
|
withoutWeightColor.includes(color) ? `bg-${color}` : `bg-${color}-${colorWeight}`,
|
||||||
|
{[`hover:bg-${color}-${colorWeight + 100}`]: !withoutWeightColor.includes(color) && !disabled}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={this.props.type}
|
||||||
|
disabled={this.props.disabled}
|
||||||
|
onClick={this.props.onClick}
|
||||||
|
className={classNames(
|
||||||
|
'focus:outline-none rounded-md',
|
||||||
|
this.getColorClass(),
|
||||||
|
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||||
|
this.props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledButton.defaultProps = {
|
||||||
|
type: 'button',
|
||||||
|
color: 'blue',
|
||||||
|
colorWeight: 500,
|
||||||
|
borderWeight: 1,
|
||||||
|
loading: false,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
FilledButton.propTypes = {
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
|
colorWeight: PropTypes.number.isRequired,
|
||||||
|
borderWeight: PropTypes.number.isRequired,
|
||||||
|
textColor: PropTypes.string,
|
||||||
|
textColorWeight: PropTypes.string,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilledButton;
|
||||||
61
src/components/shared/buttons/flat-button.js
Normal file
61
src/components/shared/buttons/flat-button.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import {withoutWeightColor} from './utils';
|
||||||
|
|
||||||
|
class FlatButton extends Component {
|
||||||
|
getColorClass = () => {
|
||||||
|
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
|
||||||
|
|
||||||
|
return classNames(
|
||||||
|
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||||
|
{[`text-${color}-${colorWeight}`]: !textColor},
|
||||||
|
{'opacity-40 cursor-not-allowed': disabled},
|
||||||
|
{[`hover:bg-${color}-100`]: !withoutWeightColor.includes(color) && !disabled}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={this.props.type}
|
||||||
|
disabled={this.props.disabled}
|
||||||
|
onClick={this.props.onClick}
|
||||||
|
className={classNames(
|
||||||
|
'focus:outline-none rounded-md',
|
||||||
|
this.getColorClass(),
|
||||||
|
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||||
|
this.props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlatButton.defaultProps = {
|
||||||
|
type: 'button',
|
||||||
|
color: 'blue',
|
||||||
|
colorWeight: 500,
|
||||||
|
borderWeight: 1,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
FlatButton.propTypes = {
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
|
colorWeight: PropTypes.number.isRequired,
|
||||||
|
borderWeight: PropTypes.number.isRequired,
|
||||||
|
textColor: PropTypes.string,
|
||||||
|
textColorWeight: PropTypes.string,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FlatButton;
|
||||||
56
src/components/shared/buttons/nav-link.js
Normal file
56
src/components/shared/buttons/nav-link.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Button from './button';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
function NavLink(props) {
|
||||||
|
const content = (
|
||||||
|
<Button
|
||||||
|
color={props.color}
|
||||||
|
outline={props.outline}
|
||||||
|
flat={props.flat}
|
||||||
|
size={props.size}
|
||||||
|
textSize={props.textSize}
|
||||||
|
loading={props.loading}
|
||||||
|
disabled={props.disabled}
|
||||||
|
className={classNames(props.className, 'min-w-max')}
|
||||||
|
>
|
||||||
|
{props.content || props.children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (props.disabled) return content;
|
||||||
|
return (
|
||||||
|
<Link to={props.to} replace={props.replace} className={props.className}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NavLink.defaultProps = {
|
||||||
|
outline: false,
|
||||||
|
flat: false,
|
||||||
|
size: 'base',
|
||||||
|
textSize: 'sm',
|
||||||
|
color: 'blue',
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
replace: false
|
||||||
|
};
|
||||||
|
|
||||||
|
NavLink.propTypes = {
|
||||||
|
content: PropTypes.string,
|
||||||
|
to: PropTypes.string.isRequired,
|
||||||
|
color: PropTypes.string,
|
||||||
|
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||||
|
outline: PropTypes.bool.isRequired,
|
||||||
|
flat: PropTypes.bool.isRequired,
|
||||||
|
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
replace: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavLink;
|
||||||
64
src/components/shared/buttons/outline-button.js
Normal file
64
src/components/shared/buttons/outline-button.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import {withoutWeightColor} from './utils';
|
||||||
|
|
||||||
|
class OutlineButton extends Component {
|
||||||
|
getColorClass = () => {
|
||||||
|
const {color, colorWeight, borderWeight, textColor, textColorWeight, disabled} = this.props;
|
||||||
|
|
||||||
|
return classNames(
|
||||||
|
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||||
|
{[`text-${color}-${colorWeight}`]: !textColor},
|
||||||
|
{'opacity-40 cursor-not-allowed': disabled},
|
||||||
|
(borderWeight === 1) ? 'border' : `border-${borderWeight}`,
|
||||||
|
withoutWeightColor.includes(color) ? `border-${color}` : `border-${color}-${colorWeight}`,
|
||||||
|
{[`hover:bg-${color}-50`]: !withoutWeightColor.includes(color) && !disabled}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={this.props.type}
|
||||||
|
disabled={this.props.disabled}
|
||||||
|
onClick={this.props.onClick}
|
||||||
|
className={classNames(
|
||||||
|
'focus:outline-none rounded-md',
|
||||||
|
this.getColorClass(),
|
||||||
|
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||||
|
this.props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlineButton.defaultProps = {
|
||||||
|
type: 'button',
|
||||||
|
color: 'blue',
|
||||||
|
colorWeight: 500,
|
||||||
|
borderWeight: 1,
|
||||||
|
loading: false,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
OutlineButton.propTypes = {
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
|
colorWeight: PropTypes.number.isRequired,
|
||||||
|
borderWeight: PropTypes.number.isRequired,
|
||||||
|
textColor: PropTypes.string,
|
||||||
|
textColorWeight: PropTypes.string,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
disabled: PropTypes.bool.isRequired,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OutlineButton;
|
||||||
54
src/components/shared/buttons/scroll-to-top.js
Normal file
54
src/components/shared/buttons/scroll-to-top.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {Component} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {ChevronUpIcon} from '@heroicons/react/solid';
|
||||||
|
import Button from './button';
|
||||||
|
|
||||||
|
class ScrollToTop extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {isVisible: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.addEventListener('scroll', this.handleScrollEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
document.removeEventListener('scroll', this.handleScrollEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScrollEvent = () => {
|
||||||
|
this.setState({isVisible: window.pageYOffset > 300});
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToTop = () => {
|
||||||
|
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
circular
|
||||||
|
color='gray'
|
||||||
|
colorWeight={400}
|
||||||
|
size='lg'
|
||||||
|
onClick={this.scrollToTop}
|
||||||
|
className={`fixed bottom-${this.props.withFooter ? 16 : 5} left-5 rounded-full text-white`}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon width={24} height={24}/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollToTop.defaultProps = {
|
||||||
|
withFooter: false
|
||||||
|
};
|
||||||
|
|
||||||
|
ScrollToTop.propTypes = {
|
||||||
|
withFooter: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToTop;
|
||||||
1
src/components/shared/buttons/utils.js
Normal file
1
src/components/shared/buttons/utils.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const withoutWeightColor = ['inherit', 'current', 'transparent', 'black', 'white'];
|
||||||
20
src/components/shared/container.js
Normal file
20
src/components/shared/container.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
function Container(props) {
|
||||||
|
return (
|
||||||
|
<div className={classNames('container m-auto', props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Container.propTypes = {
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Container;
|
||||||
3
src/components/shared/divider.css
Normal file
3
src/components/shared/divider.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.divider {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
18
src/components/shared/divider.js
Normal file
18
src/components/shared/divider.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import './divider.css';
|
||||||
|
|
||||||
|
function Divider(props) {
|
||||||
|
return (
|
||||||
|
<div className={classNames('divider', 'w-100 mx-0 my-6 border-b text-center', props.className)}>
|
||||||
|
{props.content && <span className='bg-white px-0'>{props.content}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider.propTypes = {
|
||||||
|
content: PropTypes.string,
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Divider;
|
||||||
25
src/components/shared/error.js
Normal file
25
src/components/shared/error.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
function Error(props) {
|
||||||
|
const {error} = props;
|
||||||
|
return (
|
||||||
|
<div className='text-center'>
|
||||||
|
<p>خطایی رخ داده است.</p>
|
||||||
|
{error.status && (
|
||||||
|
<p>
|
||||||
|
<span>کد خطا: </span>
|
||||||
|
<span>{error.status}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error.data && (
|
||||||
|
<p>{JSON.stringify(error.data)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Error.propTypes = {
|
||||||
|
error: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Error;
|
||||||
70
src/components/shared/fetches/fetcher.js
Normal file
70
src/components/shared/fetches/fetcher.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {useEffect} from 'react';
|
||||||
|
import {bindActionCreators} from 'redux';
|
||||||
|
import {connect} from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Loader from '../loader';
|
||||||
|
import Error from '../error';
|
||||||
|
import {fetchData} from '../../utils/fetch';
|
||||||
|
import {ERROR, INIT, LOADING} from '../../../redux/constants';
|
||||||
|
|
||||||
|
function Fetcher(props) {
|
||||||
|
useEffect(
|
||||||
|
() => fetchData(props.action),
|
||||||
|
[props.action]
|
||||||
|
);
|
||||||
|
// Loading
|
||||||
|
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
|
||||||
|
// Error
|
||||||
|
if (props.status === ERROR) {
|
||||||
|
const result = props.renderError(props.error);
|
||||||
|
if (result !== null) return result;
|
||||||
|
}
|
||||||
|
// Empty Data
|
||||||
|
if (
|
||||||
|
(Array.isArray(props.data) && props.data.length === 0) ||
|
||||||
|
(Object.entries(props.data).length === 0 && props.data.constructor === Object)
|
||||||
|
) {
|
||||||
|
const result = props.renderEmpty();
|
||||||
|
if (result !== null) return result;
|
||||||
|
}
|
||||||
|
// Good Condition
|
||||||
|
return props.children(props.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Fetcher.propTypes = {
|
||||||
|
action: PropTypes.func.isRequired,
|
||||||
|
stateSelector: PropTypes.func.isRequired,
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
data: PropTypes.oneOfType([
|
||||||
|
PropTypes.object,
|
||||||
|
PropTypes.arrayOf(PropTypes.object)
|
||||||
|
]).isRequired,
|
||||||
|
renderEmpty: PropTypes.func.isRequired,
|
||||||
|
renderError: PropTypes.func.isRequired,
|
||||||
|
error: PropTypes.oneOfType(PropTypes.string, PropTypes.object),
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node),
|
||||||
|
PropTypes.func
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
Fetcher.defaultProps = {
|
||||||
|
renderEmpty: () => null,
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
renderError: () => <Error/>
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapStateToProps(state, {stateSelector}) {
|
||||||
|
return {
|
||||||
|
status: stateSelector(state).status,
|
||||||
|
error: stateSelector(state).error,
|
||||||
|
data: stateSelector(state).data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch, {action}) {
|
||||||
|
return bindActionCreators({action}, dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Fetcher);
|
||||||
83
src/components/shared/fetches/multi-fetcher.js
Normal file
83
src/components/shared/fetches/multi-fetcher.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {useEffect} from 'react';
|
||||||
|
import {bindActionCreators} from 'redux';
|
||||||
|
import {connect} from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Loader from '../loader';
|
||||||
|
import {fetchData, mergeStatuses} from '../../utils/fetch';
|
||||||
|
import {ERROR, INIT, LOADING} from '../../../redux/constants';
|
||||||
|
import Error from '../error';
|
||||||
|
|
||||||
|
function MultiFetcher(props) {
|
||||||
|
useEffect(
|
||||||
|
() => fetchData(props.action),
|
||||||
|
[props.action]
|
||||||
|
);
|
||||||
|
// Loading
|
||||||
|
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
|
||||||
|
// Error
|
||||||
|
if (props.status === ERROR) return props.renderError(props.errors);
|
||||||
|
// Empty Data
|
||||||
|
const emptyDataKeys = [];
|
||||||
|
for (const key in props.data) {
|
||||||
|
const data = props.data[key];
|
||||||
|
if (
|
||||||
|
(Array.isArray(data) && data.length === 0) ||
|
||||||
|
(Object.entries(data).length === 0 && data.constructor === Object)
|
||||||
|
) emptyDataKeys.push(key);
|
||||||
|
}
|
||||||
|
if (emptyDataKeys.length !== 0) {
|
||||||
|
const result = props.renderEmpty(emptyDataKeys);
|
||||||
|
if (result !== null) return result;
|
||||||
|
}
|
||||||
|
// Good Condition
|
||||||
|
return props.children(props.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiFetcher.propTypes = {
|
||||||
|
actions: PropTypes.arrayOf(PropTypes.func).isRequired,
|
||||||
|
action: PropTypes.func.isRequired,
|
||||||
|
stateSelectors: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
data: PropTypes.object.isRequired,
|
||||||
|
renderEmpty: PropTypes.func.isRequired,
|
||||||
|
renderError: PropTypes.func.isRequired,
|
||||||
|
errors: PropTypes.object.isRequired,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node),
|
||||||
|
PropTypes.func
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiFetcher.defaultProps = {
|
||||||
|
renderEmpty: () => null,
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
renderError: () => <Error/>
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapStateToProps(state, {stateSelectors}) {
|
||||||
|
const statuses = [];
|
||||||
|
const data = {};
|
||||||
|
const errors = {};
|
||||||
|
for (const key in stateSelectors) {
|
||||||
|
if (!(key in stateSelectors)) continue;
|
||||||
|
const selectedState = stateSelectors[key](state);
|
||||||
|
statuses.push(selectedState.status);
|
||||||
|
data[key] = selectedState.data;
|
||||||
|
if (selectedState.status === ERROR) errors[key] = selectedState.error;
|
||||||
|
}
|
||||||
|
return {status: mergeStatuses(statuses), data, errors};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch, {actions}) {
|
||||||
|
function action() {
|
||||||
|
return function (dispatch) {
|
||||||
|
for (let i = 0; i < actions.length; i++)
|
||||||
|
dispatch(actions[i]());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindActionCreators({action}, dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(MultiFetcher);
|
||||||
47
src/components/shared/forms/input.js
Normal file
47
src/components/shared/forms/input.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
function FormInput({field, form, inline, ...props}) {
|
||||||
|
const id = props.id || field.name;
|
||||||
|
const label = props.label || field.name;
|
||||||
|
const placeholder = props.placeholder || label;
|
||||||
|
const type = props.type || 'input';
|
||||||
|
const exactLabel = ['.', '?', '!', '؟'].includes(label.charAt(label.length - 1)) ?
|
||||||
|
label.substring(0, label.length - 1) :
|
||||||
|
label;
|
||||||
|
const error = form.errors[field.name];
|
||||||
|
const touched = form.touched[field.name];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(
|
||||||
|
{'inline ': inline},
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={classNames(
|
||||||
|
inline ? 'block sm:inline sm:ml-4 my-2' : 'block mb-3',
|
||||||
|
'text-sm font-medium text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{`${exactLabel} :`}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 rounded-md border border-gray-300 shadow-sm sm:text-sm',
|
||||||
|
'focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none',
|
||||||
|
inline ? 'block sm:inline sm:ml-4 my-2' : 'block w-full'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && touched && <div className='mt-1 px-2 text-red-400 text-sm'>{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormInput;
|
||||||
55
src/components/shared/forms/validations.js
Normal file
55
src/components/shared/forms/validations.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// validation functions
|
||||||
|
export function required(value) {
|
||||||
|
return (value != null) ? undefined : 'وارد کردن این فیلد الزامی است.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function number(value) {
|
||||||
|
return (value != null) ? 'وارد کردن این فیلد الزامی است.' :
|
||||||
|
isNaN(Number(value)) ?
|
||||||
|
'لطفا یک عدد وارد نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function email(value) {
|
||||||
|
return !value ?
|
||||||
|
'وارد کردن این فیلد الزامی است.' :
|
||||||
|
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) ?
|
||||||
|
'لطفا یک ایمیل معتبر وارد نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mobile(value) {
|
||||||
|
return !value ?
|
||||||
|
'وارد کردن این فیلد الزامی است.' :
|
||||||
|
!/^[0][9][0-9]{9}$/i.test(value) ?
|
||||||
|
'لطفا یک شماره تلفن همراه معتبر با حروف انگلیسی وارد نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function password(value) {
|
||||||
|
return !value ?
|
||||||
|
'وارد کردن این فیلد الزامی است.' :
|
||||||
|
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/i.test(value) ?
|
||||||
|
'لطفا یک رمزعبور دارای حداقل 8 کاراکتر و ترکیبی از حروف انگلیسی و اعداد وارد نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function smsKey(value) {
|
||||||
|
return !value ?
|
||||||
|
'وارد کردن این فیلد الزامی است.' :
|
||||||
|
!/^[0-9]{6}$/i.test(value) ?
|
||||||
|
'لطفا کد 6 رقمی پیامک شده به شماره خود را وارد نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gender(value) {
|
||||||
|
return !['M', 'F'].includes(value) ? 'وارد کردن این فیلد الزامی است.' : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persian(value) {
|
||||||
|
return !value ?
|
||||||
|
'وارد کردن این فیلد الزامی است.' :
|
||||||
|
/[^آابپتثجچحخدذرزژسشصضطظعغفقکگلمنوهی ئء]/.test(value) ?
|
||||||
|
'لطفا از حروف فارسی استفاده نمایید.' :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
24
src/components/shared/loader.js
Normal file
24
src/components/shared/loader.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {ReactComponent as Spinner} from './spinner.svg';
|
||||||
|
import './spinner.css';
|
||||||
|
|
||||||
|
function Loader(props) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col justify-center items-center h-screen text-center'>
|
||||||
|
<Spinner width='70' className='spinner'/>
|
||||||
|
<div className='mt-5'>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader.defaultProps = {
|
||||||
|
content: 'لطفا منتظر بمانید.'
|
||||||
|
};
|
||||||
|
|
||||||
|
Loader.propTypes = {
|
||||||
|
content: PropTypes.string.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loader;
|
||||||
67
src/components/shared/modal.js
Normal file
67
src/components/shared/modal.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {Fragment} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
function Modal(props) {
|
||||||
|
if (!props.showModal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'justify-center items-center flex overflow-x-hidden overflow-y-auto fixed \
|
||||||
|
inset-0 z-50 outline-none focus:outline-none'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='relative w-auto my-6 mx-auto w-4/5 max-w-6xl'>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white \
|
||||||
|
outline-none focus:outline-none'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
'absolute top-0 left-0 z-10 p-1 mr-auto bg-transparent border-0 text-black opacity-50 float-left \
|
||||||
|
text-3xl leading-none font-semibold outline-none focus:outline-none'
|
||||||
|
}
|
||||||
|
type='button'
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'bg-transparent text-black opacity-50 h-6 w-6 text-2xl block outline-none focus:outline-none'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{props.header && (
|
||||||
|
<div className='flex items-start justify-between p-5 border-b border-solid border-blueGray-200 rounded-t'>
|
||||||
|
<p className='text-2xl font-medium'>{props.header}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='relative p-6 flex-auto'>
|
||||||
|
<p className='my-4 text-blueGray-500 text-lg leading-relaxed'>
|
||||||
|
{props.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-end p-4 border-t border-solid border-blueGray-200 rounded-b'>
|
||||||
|
{props.footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='opacity-75 fixed inset-0 z-40 bg-black'/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.propTypes = {
|
||||||
|
showModal: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
header: PropTypes.node,
|
||||||
|
body: PropTypes.node.isRequired,
|
||||||
|
footer: PropTypes.node.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
51
src/components/shared/segment.js
Normal file
51
src/components/shared/segment.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
function Segment(props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={props.id}
|
||||||
|
className={classNames(
|
||||||
|
'm-4 p-4 overflow-y-auto',
|
||||||
|
'border-2 rounded shadow-sm',
|
||||||
|
getBorderClass(props.borderWeight, props.color, props.colorWeight),
|
||||||
|
getTextAlignClass(props.textAlign),
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextAlignClass(textAlign) {
|
||||||
|
return `text-${textAlign}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBorderClass(borderWeight, color, colorWeight) {
|
||||||
|
const borderClass = borderWeight === 1 ? 'border' : `border-${borderWeight}`;
|
||||||
|
const borderColorClass = color ?
|
||||||
|
colorWeight ? `border-${color}-${colorWeight}` : `border-${color}-${colorWeight}` :
|
||||||
|
'border-gray-300';
|
||||||
|
return `${borderClass} ${borderColorClass}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Segment.defaultProps = {
|
||||||
|
textAlign: 'start',
|
||||||
|
borderWeight: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Segment.propTypes = {
|
||||||
|
id: PropTypes.string,
|
||||||
|
color: PropTypes.string,
|
||||||
|
colorWeight: PropTypes.number,
|
||||||
|
borderWeight: PropTypes.number.isRequired,
|
||||||
|
textAlign: PropTypes.oneOf(['start', 'center', 'end']).isRequired,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.node,
|
||||||
|
PropTypes.arrayOf(PropTypes.node)
|
||||||
|
]),
|
||||||
|
className: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Segment;
|
||||||
44
src/components/shared/spinner.css
Normal file
44
src/components/shared/spinner.css
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
svg.spinner{
|
||||||
|
animation: 2s linear infinite spinner-animation;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-animation {
|
||||||
|
0% {
|
||||||
|
transform: rotateZ(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotateZ(360deg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.spinner circle {
|
||||||
|
animation: 1.4s ease-in-out infinite both spinner-circle-animation;
|
||||||
|
display: block;
|
||||||
|
fill: transparent;
|
||||||
|
stroke: #2f3d4c;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 283;
|
||||||
|
stroke-dashoffset: 280;
|
||||||
|
stroke-width: 8px;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-circle-animation {
|
||||||
|
0%,
|
||||||
|
25% {
|
||||||
|
stroke-dashoffset: 280;
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50%,
|
||||||
|
75% {
|
||||||
|
stroke-dashoffset: 75;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 280;
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/components/shared/spinner.svg
Normal file
3
src/components/shared/spinner.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="50" cy="50" r="45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 104 B |
10
src/components/shared/use-user.js
Normal file
10
src/components/shared/use-user.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {useUser} from '../../hooks/user';
|
||||||
|
|
||||||
|
export function UseUser(props) {
|
||||||
|
return props.children(useUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
UseUser.propTypes = {
|
||||||
|
children: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
19
src/components/students-count/container.js
Normal file
19
src/components/students-count/container.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import StudentsCountChart from './students-count-chart';
|
||||||
|
import {useStudentsCountData} from '../../hooks/student';
|
||||||
|
import Loader from '../shared/loader';
|
||||||
|
import Error from '../shared/error';
|
||||||
|
|
||||||
|
function StudentsCountContainer() {
|
||||||
|
const {isLoading, isError, data, error} = useStudentsCountData();
|
||||||
|
|
||||||
|
if (isLoading) return <Loader />;
|
||||||
|
if (isError) return <Error error={error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative mx-auto h-[45vh] w-[80vw] sm:h-[60vh] md:h-[80vh]'>
|
||||||
|
<StudentsCountChart studentsData={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StudentsCountContainer;
|
||||||
12
src/components/students-count/index.js
Normal file
12
src/components/students-count/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import MainLayout from '../layouts/main';
|
||||||
|
import StudentsCountContainer from './container';
|
||||||
|
|
||||||
|
function StudentsCountPage() {
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<StudentsCountContainer />
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StudentsCountPage;
|
||||||
93
src/components/students-count/students-count-chart.js
Normal file
93
src/components/students-count/students-count-chart.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Chart as ChartJS, defaults, registerables} from 'chart.js';
|
||||||
|
import {Line} from 'react-chartjs-2';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import jalaliFormat from 'date-fns-jalali/format/index.js';
|
||||||
|
import 'chartjs-adapter-date-fns';
|
||||||
|
|
||||||
|
function StudentsCountChart(props) {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
options={options}
|
||||||
|
data={{
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'تعداد کاربرها',
|
||||||
|
data: props.studentsData.map(({date: x, count: y}) => ({x, y})),
|
||||||
|
fill: false,
|
||||||
|
borderColor: 'blue'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
type={'line'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChartJS.register(...registerables);
|
||||||
|
defaults.font.family = 'IRANSANS';
|
||||||
|
defaults.font.size = 16;
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
layout: {
|
||||||
|
margin: {
|
||||||
|
left: 5
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
radius: 1,
|
||||||
|
hoverRadius: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
text: 'تعداد داوطلبان پنل فلش کارت',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: function (TooltipItems) {
|
||||||
|
return TooltipItems.map(tootltipItem =>
|
||||||
|
jalaliFormat(new Date(tootltipItem.raw.x), 'yyyy MMMM dd')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day',
|
||||||
|
stepSize: 30,
|
||||||
|
tooltipFormat: 'dd-MM-yyyy',
|
||||||
|
displayFormats: {
|
||||||
|
quarter: 'MMM YYYY'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'روز'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value, index, ticks) => jalaliFormat(new Date(ticks[index].value), 'd MMMM')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
StudentsCountChart.propTypes = {
|
||||||
|
studentsData: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentsCountChart;
|
||||||
15
src/components/utils/fetch.js
Normal file
15
src/components/utils/fetch.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import {ERROR, INIT, LOADING, OK} from '../../redux/constants';
|
||||||
|
|
||||||
|
export function fetchData(fetchFunction) {
|
||||||
|
fetchFunction();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeStatuses(statuses) {
|
||||||
|
let isOK = true;
|
||||||
|
for (let i = 0; i < statuses.length; i++) {
|
||||||
|
if (statuses[i] === INIT) return INIT;
|
||||||
|
if (statuses[i] === ERROR) return ERROR;
|
||||||
|
isOK = isOK && (statuses[i] === OK);
|
||||||
|
}
|
||||||
|
return isOK ? OK : LOADING;
|
||||||
|
}
|
||||||
1
src/components/utils/index.js
Normal file
1
src/components/utils/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const BLANK_TEXT = '---';
|
||||||
14
src/hooks/auth.js
Normal file
14
src/hooks/auth.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {useMutation, useQueryClient} from 'react-query';
|
||||||
|
import {ACCESS_TOKEN_KEY, fetchToken, REFRESH_TOKEN_KEY} from '../api/auth';
|
||||||
|
|
||||||
|
export function useLoginMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
function onSuccess({access, refresh}) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||||
|
queryClient.invalidateQueries('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return useMutation(fetchToken, {onSuccess});
|
||||||
|
}
|
||||||
0
src/hooks/index.js
Normal file
0
src/hooks/index.js
Normal file
6
src/hooks/order.js
Normal file
6
src/hooks/order.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import {useQuery} from 'react-query';
|
||||||
|
import {fetchOrderData} from '../api/order';
|
||||||
|
|
||||||
|
export function useOrdersData() {
|
||||||
|
return useQuery('orders-data', fetchOrderData);
|
||||||
|
}
|
||||||
7
src/hooks/query-params.js
Normal file
7
src/hooks/query-params.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {useLocation} from 'react-router-dom';
|
||||||
|
|
||||||
|
function useQueryParams() {
|
||||||
|
return Object.fromEntries(new URLSearchParams(useLocation().search));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useQueryParams;
|
||||||
6
src/hooks/student.js
Normal file
6
src/hooks/student.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import {useQuery} from 'react-query';
|
||||||
|
import {fetchStudentsCountData} from '../api/student';
|
||||||
|
|
||||||
|
export function useStudentsCountData() {
|
||||||
|
return useQuery('users-data', fetchStudentsCountData);
|
||||||
|
}
|
||||||
7
src/hooks/user.js
Normal file
7
src/hooks/user.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {useQuery} from 'react-query';
|
||||||
|
import {fetchUser} from '../api/user';
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const data = useQuery('user', fetchUser, {staleTime: 5 * 60 * 1000, retry: false});
|
||||||
|
return {...data, isAuthenticated: data.status === 'success'};
|
||||||
|
}
|
||||||
41
src/index.css
Normal file
41
src/index.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
b, strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#question ol, #question ul,
|
||||||
|
#answer ol, #answer ul {
|
||||||
|
@apply list-disc list-inside;
|
||||||
|
@apply ps-4 m-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#question table, #question img,
|
||||||
|
#answer table, #answer img {
|
||||||
|
@apply mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#question table tr, #question table td,
|
||||||
|
#answer table tr, #answer table td {
|
||||||
|
@apply border-collapse border border-black;
|
||||||
|
@apply text-center p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
33
src/index.js
Normal file
33
src/index.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import {QueryClient, QueryClientProvider} from 'react-query';
|
||||||
|
import {ReactQueryDevtools} from 'react-query/devtools';
|
||||||
|
import {ToastProvider} from 'react-toast-notifications';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ToastProvider autoDismiss>
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</React.StrictMode>
|
||||||
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
13
src/reportWebVitals.js
Normal file
13
src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const reportWebVitals = onPerfEntry => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
9
src/setupTests.js
Normal file
9
src/setupTests.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import Enzyme from 'enzyme';
|
||||||
|
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||||
|
|
||||||
|
Enzyme.configure({adapter: new Adapter()});
|
||||||
19
tailwind.config.js
Normal file
19
tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/components/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./public/index.html',
|
||||||
|
'./tailwind.safetext.txt'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
screens: {
|
||||||
|
'xs': '475px'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('tailwindcss-rtl')
|
||||||
|
]
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user