commit bf5c561a246aa13bcf20b52e2d4cf014b8e9933b Author: mho3ein220 Date: Sat Aug 8 18:56:53 2026 +0330 start diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ca21ec0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,69 @@ +{ + "env": { + "browser": true, + "es2021": true, + "jest": true + }, + "extends": [ + "plugin:react/recommended", + "standard", + "prettier" + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "parser": "@babel/eslint-parser", + "plugins": [ + "react", + "react-hooks", + "prettier" + ], + "rules": { + "prettier/prettier": [ + "error" + ], + "react/react-in-jsx-scope": "off", + "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" + ], + "multiline-ternary": "off", + "no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true + } + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbbc75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +build/ + +.env* + +# Local Claude Code config — settings.local.json holds per-machine permissions. +.claude/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5b4c04e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,16 @@ +{ + "trailingComma": "none", + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "printWidth": 100, + "plugins": [ + "prettier-plugin-tailwindcss" + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ed38c2 --- /dev/null +++ b/package.json @@ -0,0 +1,87 @@ +{ + "name": "student_panel", + "version": "0.1.0", + "private": true, + "dependencies": { + "@headlessui/react": "^1.5.0", + "@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", + "axios-cache-adapter": "^2.7.3", + "classnames": "^2.3.1", + "formik": "^2.2.9", + "moment-jalaali": "^0.9.4", + "prop-types": "^15.8.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-ga": "^3.3.1", + "react-redux": "^7.2.6", + "react-router-dom": "^5.2.0", + "react-scripts": "5.0.0", + "react-toast-notifications": "^2.5.1", + "recharts": "^2.1.8", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", + "trendline": "^0.3.1", + "web-vitals": "^2.1.2", + "workbox-cacheable-response": "^6.5.3", + "workbox-core": "^6.5.3", + "workbox-expiration": "^6.5.3", + "workbox-precaching": "^6.5.3", + "workbox-routing": "^6.5.3", + "workbox-strategies": "^6.5.3", + "workbox-window": "^6.5.3" + }, + "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-prettier": "^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-prettier": "^4.0.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", + "prettier": "^2.6.1", + "prettier-plugin-tailwindcss": "^0.1.8", + "react-test-renderer": "^17.0.2", + "redux-devtools-extension": "^2.13.9", + "tailwindcss": "^3.0.8", + "tailwindcss-rtl": "^0.8.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/assets/css/fontiran.css b/public/assets/css/fontiran.css new file mode 100644 index 0000000..28247ce --- /dev/null +++ b/public/assets/css/fontiran.css @@ -0,0 +1,78 @@ +/** +* +* Name: IRAN Sans-Serif Font +* Version: 5.0 +* Author: Moslem Ebrahimi (moslemebrahimi.com) +* Created on: Dec 25, 2012 +* Updated on: Sep 01, 2017 +* Website: http://fontiran.com +* Copyright: Commercial/Proprietary Software +-------------------------------------------------------------------------------------- +فونت های ایران سن سریف یک نرم افزار مالکیتی محسوب می شود. جهت آگاهی از قوانین استفاده از این فونت ها لطفا به وب سایت (فونت ایران دات کام) مراجعه نمایید +-------------------------------------------------------------------------------------- +IRAN Sans-serif fonts are considered a proprietary software. To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com +-------------------------------------------------------------------------------------- +This set of fonts are used in this project under the license: (GY3QV) +-------------------------------------------------------------------------------------- +* +**/ +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: 900; + src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum)_Black.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum)_Black.ttf') format('truetype'); +} +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: bold; + src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum)_Bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf') format('truetype'); +} +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: 500; + src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum)_Medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf') format('truetype'); +} +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: 300; + src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum)_Light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum)_Light.ttf') format('truetype'); +} +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: 200; + src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf') format('truetype'); +} +@font-face { + font-family: IRANSans; + font-style: normal; + font-weight: normal; + src: url('../fonts/eot/IRANSansWeb(FaNum).eot'); + src: url('../fonts/eot/IRANSansWeb(FaNum).eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('../fonts/woff2/IRANSansWeb(FaNum).woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/ + url('../fonts/woff/IRANSansWeb(FaNum).woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('../fonts/ttf/IRANSansWeb(FaNum).ttf') format('truetype'); +} diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..ba3bb4c --- /dev/null +++ b/public/assets/css/style.css @@ -0,0 +1,71 @@ +@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; +} + +.plain-text { + unicode-bidi: plaintext; + unicode-bidi: -moz-plaintext; +} diff --git a/public/assets/css/style.min.css b/public/assets/css/style.min.css new file mode 100644 index 0000000..c89bb30 --- /dev/null +++ b/public/assets/css/style.min.css @@ -0,0 +1 @@ +@import url(./fontiran.css);@keyframes progressBar{0%,50.01%{width:0;background-color:transparent}49.99%{width:100%;background-color:#00b1da}to{width:100%;background-color:#fb8928}}body{font-weight:300;direction:rtl;margin:0}a,body,button,div,h1,h2,h3,h4,h5,h6,input,label,span,strong,text,textarea{font-family:IRANSans!important}.preloader-container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column}.meter{height:10px;width:172px;background:#f3efe6;direction:ltr;border-radius:5px;margin:12px 0}.progress{height:100%;display:block;border-radius:5px;animation:progressBar 1.2s ease-in-out infinite;animation-fill-mode:both}.leading-0{line-height:0}.plain-text{unicode-bidi:plaintext;unicode-bidi:-moz-plaintext} \ No newline at end of file diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum).eot b/public/assets/fonts/eot/IRANSansWeb(FaNum).eot new file mode 100644 index 0000000..5a6fdd0 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum).eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot new file mode 100644 index 0000000..a012bc6 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Black.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot new file mode 100644 index 0000000..82525e5 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Bold.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot new file mode 100644 index 0000000..f899008 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Light.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot new file mode 100644 index 0000000..117c2c8 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_Medium.eot differ diff --git a/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot b/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot new file mode 100644 index 0000000..9cd9b66 Binary files /dev/null and b/public/assets/fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf new file mode 100644 index 0000000..cd74265 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum).ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf new file mode 100644 index 0000000..0f84dbf Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Black.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf new file mode 100644 index 0000000..6fa2412 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf new file mode 100644 index 0000000..d9000c9 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Light.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf new file mode 100644 index 0000000..e20001c Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf differ diff --git a/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf new file mode 100644 index 0000000..0072606 Binary files /dev/null and b/public/assets/fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum).woff b/public/assets/fonts/woff/IRANSansWeb(FaNum).woff new file mode 100644 index 0000000..e34dfc4 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum).woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff new file mode 100644 index 0000000..bbbca77 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Black.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff new file mode 100644 index 0000000..05d933e Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Bold.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff new file mode 100644 index 0000000..fe80d47 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Light.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff new file mode 100644 index 0000000..f13a870 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_Medium.woff differ diff --git a/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff b/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff new file mode 100644 index 0000000..9c653a4 Binary files /dev/null and b/public/assets/fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 new file mode 100644 index 0000000..30a377d Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum).woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 new file mode 100644 index 0000000..0b15441 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Black.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 new file mode 100644 index 0000000..f2353b2 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 new file mode 100644 index 0000000..562d663 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Light.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 new file mode 100644 index 0000000..a69f4d0 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2 differ diff --git a/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 new file mode 100644 index 0000000..1114f71 Binary files /dev/null and b/public/assets/fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 differ diff --git a/public/browserconfig.xml b/public/browserconfig.xml new file mode 100644 index 0000000..e636aa5 --- /dev/null +++ b/public/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #ffffff + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e4a41cb --- /dev/null +++ b/public/index.html @@ -0,0 +1,158 @@ + + + + + + + + + پنل فلش کارت گروه مد مشاور + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ logo +
+ +
+
+
+ + + diff --git a/public/lib/.gitkeep b/public/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..83d8fc0 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,83 @@ +{ + "short_name": "Medmoshaver FlashCard Panel", + "name": "Medmoshaver FlashCard Panel", + "icons": [ + { + "src": "https://medmoshaver.com/icons/pwa/icon-36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-256.png", + "sizes": "256x256", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "density": "8.0", + "purpose": "maskable any" + }, + { + "src": "https://medmoshaver.com/icons/pwa/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#fafafa", + "background_color": "#fafafa" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/voices/reset-guide.mp3 b/public/voices/reset-guide.mp3 new file mode 100644 index 0000000..ea17a15 Binary files /dev/null and b/public/voices/reset-guide.mp3 differ diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..252c33b --- /dev/null +++ b/src/App.js @@ -0,0 +1,75 @@ +import React, { lazy, Suspense, useEffect } from 'react'; +import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import ReactGA from 'react-ga'; +import HomePage from './components/home'; +import LoginPage from './components/login'; +import RegisterPage from './components/register'; +import CardPage from './components/card'; +import LastReviewPage from './components/last-review'; +import CheckCardPage from './components/check-card'; +import DecksPage from './components/decks'; +import StorePage from './components/store'; +import OrderPage from './components/order'; +import PaymentVerifyPage from './components/payment-verify'; +import ForgetPasswordPage from './components/forget-password'; +import RankingPage from './components/ranking'; +import TermsAndConditionsPage from './components/termsAndConditions'; +import DefaultPage from './components/default'; +import TopStudentScoresPage from './components/top-scores'; +import Loader from './components/shared/loader'; + +ReactGA.initialize(process.env.REACT_APP_GA_TRACKING_ID); + +import('moment-jalaali').then(moment => { + moment.default.loadPersian({ + usePersianDigits: false, + dialect: 'persian-modern' + }); +}); + +const ScoreChartPage = lazy(() => import('./components/score-chart')); + +function App() { + useEffect(() => { + ReactGA.pageview(window.location.pathname + window.location.search); + }, []); + + return ( + + }> + + + + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/App.min.css b/src/App.min.css new file mode 100644 index 0000000..e69de29 diff --git a/src/components/card/answer.js b/src/components/card/answer.js new file mode 100644 index 0000000..2d2f2e9 --- /dev/null +++ b/src/components/card/answer.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; + +function Answer(props) { + return ( + + جواب: +
+ + ); +} + +Answer.propTypes = { + answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired, + color: PropTypes.string.isRequired +}; + +export default Answer; diff --git a/src/components/card/card.js b/src/components/card/card.js new file mode 100644 index 0000000..9f560d6 --- /dev/null +++ b/src/components/card/card.js @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import OptionContainer from './option-container'; +import Question from './question'; +import Answer from './answer'; +import Reviews from './reviews'; +import Header from './header'; +import Footer from './footer'; +import { CORRECT_COLOR, DEFAULT_COLOR } from './utils'; +import { updateStudentCardRate } from '../../redux/actions/student-card'; + +function Card(props) { + const { card } = props; + const [color, setColor] = useState(DEFAULT_COLOR); + const initialRate = props.card.studentCard?.rate || 0; + const [rate, setRate] = useState(initialRate); + + useEffect(async () => { + if (rate !== initialRate && card.studentCard) { + await props.updateStudentCardRate(card.studentCard.id, rate); + } + }, [rate]); + + return ( +
+
+ + + {color !== DEFAULT_COLOR && } + {color !== DEFAULT_COLOR && } +
+
+ ); +} + +Card.propTypes = { + card: PropTypes.object.isRequired, + chapter: PropTypes.object.isRequired, + updateStudentCardRate: PropTypes.func.isRequired +}; + +export default connect(null, { updateStudentCardRate })(Card); diff --git a/src/components/card/container.js b/src/components/card/container.js new file mode 100644 index 0000000..198d1a9 --- /dev/null +++ b/src/components/card/container.js @@ -0,0 +1,32 @@ +import { useParams } from 'react-router-dom'; +import Card from './card'; +import Error from './error'; +import MultiFetcher from '../shared/fetches/multi-fetcher'; +import { fetchNextCard } from '../../redux/actions/card'; +import { fetchChapter } from '../../redux/actions/chapter'; + +function CardsContainer() { + const { chapterId, cardType } = useParams(); + const urlSearchParams = new URLSearchParams(location.search); + const priority = urlSearchParams.get('priority'); + const rateType = urlSearchParams.get('rate-type'); + const minRate = rateType ? { starred: 1, active: 0 }[rateType] : null; + + return ( + state.card, + chapter: state => state.chapter + }} + renderError={error => } + > + {({ card, chapter }) => } + + ); +} + +export default CardsContainer; diff --git a/src/components/card/error.js b/src/components/card/error.js new file mode 100644 index 0000000..2f0d9c0 --- /dev/null +++ b/src/components/card/error.js @@ -0,0 +1,80 @@ +import { useParams } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; +import NavLink from '../shared/buttons/nav-link'; +import ErrorComponent from '../shared/error'; + +function Error(props) { + const { cardType } = useParams(); + + const urlSearchParams = new URLSearchParams(location.search); + const priority = urlSearchParams.get('priority'); + const rateType = urlSearchParams.get('rate-type'); + + const { error } = props; + const backURL = `/decks?${urlSearchParams.toString()}`; + + if (error.card && error.card.status === 404) { + switch (cardType) { + case 'review': + return ( + +

+ فلش کارت + {'مروری '} + {_(priority) && {_(priority) + ' '}} + {_(rateType) && {_(rateType) + ' '}} + دیگری یافت نشد. +

+
+ +
+
+ ); + + case 'new': + return ( + +

+ فلش کارت + {'جدید '} + {_(priority) && {_(priority) + ' '}} + دیگری یافت نشد. +

+
+ +
+
+ ); + + case 'archived': + return ( + +

+ فلش کارت + {'آرشیو شده '} + {_(priority) && {_(priority) + ' '}} + {_(rateType) && {_(rateType) + ' '}} + دیگری یافت نشد. +

+
+ +
+
+ ); + default: + return null; + } + } + return ; +} + +function _(text) { + return !text ? '' : { starred: 'ستاره دار', active: 'فعال', high: 'ضروری ' }[text]; +} + +Error.propTypes = { + error: PropTypes.object.isRequired +}; + +export default Error; diff --git a/src/components/card/footer.js b/src/components/card/footer.js new file mode 100644 index 0000000..36e4c1f --- /dev/null +++ b/src/components/card/footer.js @@ -0,0 +1,98 @@ +import { Fragment, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline'; +import Button from '../shared/buttons/button'; +import { archiveStudentCard } from '../../redux/actions/student-card'; +import ConfirmModal from '../shared/confirm-modal'; +import NavLink from '../shared/buttons/nav-link'; +import { DEFAULT_DECKS_ROUTE } from '../utils/index'; + +function Footer(props) { + const history = useHistory(); + const { chapterId, cardType } = useParams(); + const [showConfirmModal, setShowConfirmModal] = useState(false); + let previousURL = `/reviews/last/ch-${chapterId}/${cardType}`; + let nextURL = `/cards/ch-${chapterId}/${cardType}`; + const urlSearchParamsSTR = new URLSearchParams(location.search).toString(); + if (urlSearchParamsSTR) { + previousURL += `?${urlSearchParamsSTR}`; + nextURL += `?${urlSearchParamsSTR}`; + } + + const archiveText = props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت'; + + return ( + + + آیا از + {archiveText} + این فلش کارت مطمئن هستید؟ +

+ } + onConfirm={async () => { + await props.archiveStudentCard(); + history.push(nextURL); + }} + onCancel={() => setShowConfirmModal(false)} + /> +
+ {props.isAnyOptionSelected ? ( + + ) : ( + + قبلی + + + )} + {props.showArchive && ( + + )} + + + بعدی + +
+
+ ); +} + +Footer.propTypes = { + isAnyOptionSelected: PropTypes.bool.isRequired, + showArchive: PropTypes.bool.isRequired, + archiveStudentCard: PropTypes.func.isRequired, + isPermanentArchive: PropTypes.bool.isRequired +}; + +function mapStateToProps({ review }, { showArchive }) { + return { + isPermanentArchive: !!review.data.studentCard && review.data.studentCard.status === 1, + showArchive: showArchive && !!review.data.studentCard && review.data.studentCard.status !== 2 + }; +} + +export default connect(mapStateToProps, { archiveStudentCard })(Footer); diff --git a/src/components/card/header.js b/src/components/card/header.js new file mode 100644 index 0000000..157af4f --- /dev/null +++ b/src/components/card/header.js @@ -0,0 +1,38 @@ +import { useParams } from 'react-router-dom'; +import Segment from '../shared/segment'; +import PropTypes from 'prop-types'; +import useQueryParams from '../../hooks/query-params'; + +function Header(props) { + const { cardType } = useParams(); + const { priority } = useQueryParams(); + + return ( + +
+
+ فلش کارت‌های + {priority && {_[priority] + ' '}} + {_[cardType]} + درس + {props.lessonName} + بخش + {props.chapter.name} +
+
+ {props.chapter[`${cardType}CardCount`]} +
+
+
+ ); +} + +const _ = { review: 'مروری', new: 'جدید', archived: 'آرشیو شده', high: 'ضروری ' }; +const color = { review: 'blue', new: 'green', archived: 'yellow' }; + +Header.propTypes = { + lessonName: PropTypes.string.isRequired, + chapter: PropTypes.object.isRequired +}; + +export default Header; diff --git a/src/components/card/icons/hide.js b/src/components/card/icons/hide.js new file mode 100644 index 0000000..a09673d --- /dev/null +++ b/src/components/card/icons/hide.js @@ -0,0 +1,26 @@ +import { LightBulbIcon as OutlineLightBulbIcon } from '@heroicons/react/outline'; +import { LightBulbIcon as SolidLightBulbIcon } from '@heroicons/react/solid'; +import PropTypes from 'prop-types'; + +function HideIcon({ hide, onUpdate }) { + return hide ? ( + + ) : ( + + ); +} + +HideIcon.propTypes = { + hide: PropTypes.bool.isRequired, + onUpdate: PropTypes.func.isRequired +}; + +export default HideIcon; diff --git a/src/components/card/icons/index.js b/src/components/card/icons/index.js new file mode 100644 index 0000000..21c46bb --- /dev/null +++ b/src/components/card/icons/index.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import StarIcon from './star'; +import HideIcon from './hide'; +import ReportIcon from './report'; + +function Icons({ cardId, rate, setRate }) { + return ( +
+ {rate >= 0 && 0} onUpdate={() => setRate(rate => 1 - rate)} />} + {rate <= 0 && setRate(rate => -1 - rate)} />} + +
+ ); +} + +Icons.propTypes = { + cardId: PropTypes.number.isRequired, + rate: PropTypes.number, + setRate: PropTypes.func.isRequired +}; + +export default Icons; diff --git a/src/components/card/icons/report.js b/src/components/card/icons/report.js new file mode 100644 index 0000000..83d27d5 --- /dev/null +++ b/src/components/card/icons/report.js @@ -0,0 +1,27 @@ +import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon'; +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import ReportModal from '../report-modal'; + +function ReportIcon({ cardId }) { + const [showModal, setShowModal] = useState(false); + + return ( + <> + setShowModal(!showModal)} + className='text-orange-400 hover:text-orange-500' + /> + {showModal && ( + setShowModal(false)} /> + )} + + ); +} + +ReportIcon.propTypes = { + cardId: PropTypes.number.isRequired +}; + +export default ReportIcon; diff --git a/src/components/card/icons/star.js b/src/components/card/icons/star.js new file mode 100644 index 0000000..7cb8756 --- /dev/null +++ b/src/components/card/icons/star.js @@ -0,0 +1,22 @@ +import { StarIcon as OutlineStarIcon } from '@heroicons/react/outline'; +import { StarIcon as SolidStarIcon } from '@heroicons/react/solid'; +import PropTypes from 'prop-types'; + +function StarIcon({ star, onUpdate }) { + return star ? ( + + ) : ( + + ); +} + +StarIcon.propTypes = { + star: PropTypes.bool.isRequired, + onUpdate: PropTypes.func.isRequired +}; + +export default StarIcon; diff --git a/src/components/card/index.js b/src/components/card/index.js new file mode 100644 index 0000000..7d654c2 --- /dev/null +++ b/src/components/card/index.js @@ -0,0 +1,12 @@ +import MainLayout from '../layouts/main'; +import CardsContainer from './container'; + +function CardPage() { + return ( + + + + ); +} + +export default CardPage; diff --git a/src/components/card/option-container.js b/src/components/card/option-container.js new file mode 100644 index 0000000..25b6a93 --- /dev/null +++ b/src/components/card/option-container.js @@ -0,0 +1,60 @@ +import { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Option from './option'; +import { createReview } from '../../redux/actions/review'; +import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils'; +import { updateStudentCardRate } from '../../redux/actions/student-card'; + +class OptionContainer extends Component { + constructor(props) { + super(props); + this.state = { + selectedOptionId: null, + options: [...props.options, { id: 0, text: 'نمی دانم', isCorrect: false }] + }; + } + + handleSelect = async optionId => { + if (this.state.selectedOptionId !== null) return null; + this.setState({ selectedOptionId: optionId }); + if (optionId !== 0) this.setState({ options: [...this.props.options] }); + const { payload: review } = await this.props.createReview(this.props.cardId, optionId); + if (this.props.rateUpdated) { + await this.props.updateStudentCardRate(review.studentCard.id, this.props.rate); + } + this.setState({ selectedOptionId: optionId }); + this.props.setColor( + optionId ? (review.status === 1 ? CORRECT_COLOR : WRONG_COLOR) : DONT_KNOW_COLOR + ); + }; + + render() { + return ( +
+ {this.state.options.map((option, index) => ( +
+ ); + } +} + +OptionContainer.propTypes = { + cardId: PropTypes.number.isRequired, + options: PropTypes.arrayOf(PropTypes.object).isRequired, + setColor: PropTypes.func.isRequired, + createReview: PropTypes.func.isRequired, + rate: PropTypes.number, + rateUpdated: PropTypes.bool.isRequired, + updateStudentCardRate: PropTypes.func.isRequired +}; + +export default connect(null, { createReview, updateStudentCardRate })(OptionContainer); diff --git a/src/components/card/option.js b/src/components/card/option.js new file mode 100644 index 0000000..db0d956 --- /dev/null +++ b/src/components/card/option.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { CheckIcon, XIcon } from '@heroicons/react/solid'; +import Button from '../shared/buttons/button'; + +function Option(props) { + const { option, isAnyOptionSelected, isSelectedOption } = props; + const color = + isAnyOptionSelected && option.isCorrect + ? 'green' + : isAnyOptionSelected && isSelectedOption && option.id + ? 'red' + : option.id + ? 'gray' + : 'yellow'; + + return ( + + ); +} + +Option.propTypes = { + option: PropTypes.object.isRequired, + index: PropTypes.number.isRequired, + isAnyOptionSelected: PropTypes.bool.isRequired, + isSelectedOption: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired +}; + +export default Option; diff --git a/src/components/card/question.js b/src/components/card/question.js new file mode 100644 index 0000000..1040dc0 --- /dev/null +++ b/src/components/card/question.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; +import { DEFAULT_COLOR } from './utils'; +import Icons from './icons'; + +function Question(props) { + return ( + +
+
+
سوال:
+
+ +
+
+
+
+ + ); +} + +Question.defaultProps = { color: DEFAULT_COLOR }; + +Question.propTypes = { + cardId: PropTypes.number.isRequired, + question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired, + color: PropTypes.string.isRequired, + rate: PropTypes.number, + setRate: PropTypes.func.isRequired +}; + +export default Question; diff --git a/src/components/card/report-modal.js b/src/components/card/report-modal.js new file mode 100644 index 0000000..904034b --- /dev/null +++ b/src/components/card/report-modal.js @@ -0,0 +1,96 @@ +import { connect } from 'react-redux'; +import { Field, Form, Formik } from 'formik'; +import PropTypes from 'prop-types'; +import Modal from '../shared/modal'; +import FormSelect from '../shared/forms/select'; +import FormTextArea from '../shared/forms/textarea'; +import Button from '../shared/buttons/button'; +import { required } from '../shared/forms/validations'; +import { createReport } from '../../redux/actions/report'; +import { useToasts } from 'react-toast-notifications'; +import { useState } from 'react'; +import Segment from '../shared/segment'; + +function ReportModal(props) { + const [error, setError] = useState(''); + const { addToast } = useToasts(); + + async function submit(values) { + try { + setError(''); + await props.createReport(values); + addToast('با تشکر، گزارش خطای شما ثبت گردید.', { appearance: 'success' }); + props.onClose(); + } catch (e) { + setError(e.statusText); + } + } + + return ( + +
+ {error && ( + +

{error}

+

لطفا مدتی بعد مجددا تلاش کنید.

+
+ )} + + + + + + } + footer={ +
+
+ } + /> + ); +} + +const types = [ + { name: 'Scientific', text: 'علمی', value: 'Scientific' }, + { name: 'Typo', text: 'نگارشی', value: 'Typo' }, + { name: 'Other', text: 'سایر', value: 'Other' } +]; + +ReportModal.propTypes = { + cardId: PropTypes.number.isRequired, + showModal: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + createReport: PropTypes.func.isRequired +}; + +export default connect(null, { createReport })(ReportModal); diff --git a/src/components/card/reviews.js b/src/components/card/reviews.js new file mode 100644 index 0000000..60447cf --- /dev/null +++ b/src/components/card/reviews.js @@ -0,0 +1,77 @@ +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { CheckIcon, XIcon } from '@heroicons/react/solid'; +import Segment from '../shared/segment'; + +function Reviews(props) { + const { color, studentCard } = props; + return ( + +
+

+ شناسه فلش کارت: + {studentCard.card} +

+ {studentCard.status > 0 && ( +

+ وضعیت: + {statuses[studentCard.status]} +

+ )} +

+ مرور شماره: + {studentCard.reviewCount} +

+
+ نتایج: +
+ {studentCard.reviews.map(review => ( + + {!review.status ? ( + + ) : review.status === 1 ? ( + + ) : ( + + )} + + ))} +
+
+
+
+ ); +} + +const statuses = ['عادی', 'آرشیو موقت', 'آرشیو دائم']; + +Reviews.propTypes = { + studentCard: PropTypes.object.isRequired, + color: PropTypes.string.isRequired +}; + +function mapStateToProps({ review }) { + return { studentCard: review.data.studentCard }; +} + +export default connect(mapStateToProps)(Reviews); diff --git a/src/components/card/utils.js b/src/components/card/utils.js new file mode 100644 index 0000000..1945be7 --- /dev/null +++ b/src/components/card/utils.js @@ -0,0 +1,4 @@ +export const DEFAULT_COLOR = 'gray'; +export const CORRECT_COLOR = 'green'; +export const WRONG_COLOR = 'red'; +export const DONT_KNOW_COLOR = 'yellow'; diff --git a/src/components/check-card/answer.js b/src/components/check-card/answer.js new file mode 100644 index 0000000..38900ca --- /dev/null +++ b/src/components/check-card/answer.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; +import { DEFAULT_COLOR } from './utils'; + +function Answer(props) { + return ( + + جواب: +
+ + ); +} + +Answer.propTypes = { + answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired +}; + +export default Answer; diff --git a/src/components/check-card/card.js b/src/components/check-card/card.js new file mode 100644 index 0000000..d7d2558 --- /dev/null +++ b/src/components/check-card/card.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import Header from './header'; +import Question from './question'; +import OptionContainer from './option-container'; +import Answer from './answer'; +import Footer from './footer'; + +function Card(props) { + const { card } = props; + + return ( +
+
+ + + +
+
+ ); +} + +Card.propTypes = { + card: PropTypes.object.isRequired +}; + +export default Card; diff --git a/src/components/check-card/container.js b/src/components/check-card/container.js new file mode 100644 index 0000000..6c833e2 --- /dev/null +++ b/src/components/check-card/container.js @@ -0,0 +1,20 @@ +import { Fragment } from 'react'; +import { useParams } from 'react-router-dom'; +import Card from './card'; +import Fetcher from '../shared/fetches/fetcher'; +import { fetchCard } from '../../redux/actions/card'; +import Footer from './footer'; + +function CardsContainer() { + const { cardId } = useParams(); + return ( + + state.card}> + {card => } + +
+ + ); +} + +export default CardsContainer; diff --git a/src/components/check-card/footer.js b/src/components/check-card/footer.js new file mode 100644 index 0000000..50bbff2 --- /dev/null +++ b/src/components/check-card/footer.js @@ -0,0 +1,48 @@ +import { Fragment } from 'react'; +import { useParams } from 'react-router-dom'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline'; +import { fetchCard } from '../../redux/actions/card'; +import NavLink from '../shared/buttons/nav-link'; + +function Footer() { + const { cardId } = useParams(); + return ( + +
+ + + بعدی + + {cardId > 1 && ( + + قبلی + + + )} +
+
+ ); +} + +Footer.propTypes = { + fetchCard: PropTypes.func.isRequired +}; + +export default connect(null, { fetchCard })(Footer); diff --git a/src/components/check-card/header.js b/src/components/check-card/header.js new file mode 100644 index 0000000..5c50808 --- /dev/null +++ b/src/components/check-card/header.js @@ -0,0 +1,26 @@ +import Segment from '../shared/segment'; +import PropTypes from 'prop-types'; + +function Header(props) { + return ( + +

+ پیش نمایش + فلش کارت‌ + {props.cardId} + درس + {props.lessonName} + بخش + {props.chapterName} +

+
+ ); +} + +Header.propTypes = { + cardId: PropTypes.number.isRequired, + lessonName: PropTypes.string.isRequired, + chapterName: PropTypes.object.isRequired +}; + +export default Header; diff --git a/src/components/check-card/index.js b/src/components/check-card/index.js new file mode 100644 index 0000000..c80f64c --- /dev/null +++ b/src/components/check-card/index.js @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; +import MainLayout from '../layouts/main'; +import CardsContainer from './container'; + +function CardPage() { + const { isSuperuser } = useSelector(({ user }) => user.data); + return {isSuperuser ? : null}; +} + +export default CardPage; diff --git a/src/components/check-card/option-container.js b/src/components/check-card/option-container.js new file mode 100644 index 0000000..ee466c3 --- /dev/null +++ b/src/components/check-card/option-container.js @@ -0,0 +1,36 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import Option from './option'; + +class OptionContainer extends Component { + constructor(props) { + super(props); + this.state = { + options: [ + ...props.options, + { + id: 0, + text: 'نمی دانم', + isCorrect: false + } + ] + }; + } + + render() { + return ( +
+ {this.state.options.map((option, index) => ( +
+ ); + } +} + +OptionContainer.propTypes = { + cardId: PropTypes.number.isRequired, + options: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default OptionContainer; diff --git a/src/components/check-card/option.js b/src/components/check-card/option.js new file mode 100644 index 0000000..983a9e7 --- /dev/null +++ b/src/components/check-card/option.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Button from '../shared/buttons/button'; +import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils'; + +function Option(props) { + const { option } = props; + + const color = option.isCorrect ? CORRECT_COLOR : option.id ? WRONG_COLOR : DONT_KNOW_COLOR; + return ( + + ); +} + +Option.propTypes = { + option: PropTypes.object.isRequired, + index: PropTypes.number.isRequired +}; + +export default Option; diff --git a/src/components/check-card/question.js b/src/components/check-card/question.js new file mode 100644 index 0000000..6fd9e63 --- /dev/null +++ b/src/components/check-card/question.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; +import { DEFAULT_COLOR } from './utils'; + +function Question(props) { + return ( + + سوال: +
+ + ); +} + +Question.defaultProps = { color: DEFAULT_COLOR }; + +Question.propTypes = { + question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired +}; + +export default Question; diff --git a/src/components/check-card/utils.js b/src/components/check-card/utils.js new file mode 100644 index 0000000..1945be7 --- /dev/null +++ b/src/components/check-card/utils.js @@ -0,0 +1,4 @@ +export const DEFAULT_COLOR = 'gray'; +export const CORRECT_COLOR = 'green'; +export const WRONG_COLOR = 'red'; +export const DONT_KNOW_COLOR = 'yellow'; diff --git a/src/components/decks/badge.js b/src/components/decks/badge.js new file mode 100644 index 0000000..d1c5793 --- /dev/null +++ b/src/components/decks/badge.js @@ -0,0 +1,37 @@ +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +function Badge(props) { + const { color } = props; + if (props.number === 0) return null; + + const content = ( +
+ {props.text} + + {props.number} + +
+ ); + + if (!props.to) return content; + return {content}; +} + +Badge.defaultProps = { to: '', color: 'blue' }; + +Badge.propTypes = { + text: PropTypes.string.isRequired, + number: PropTypes.number.isRequired, + to: PropTypes.string.isRequired, + color: PropTypes.string.isRequired +}; + +export default Badge; diff --git a/src/components/decks/chapter-modal.js b/src/components/decks/chapter-modal.js new file mode 100644 index 0000000..2618b8f --- /dev/null +++ b/src/components/decks/chapter-modal.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import Modal from '../shared/modal'; +import Button from '../shared/buttons/button'; +import NavLink from '../shared/buttons/nav-link'; + +function ChapterModal(props) { + const { chapter, priority, rateType } = props; + if (!chapter) return null; + + const noCards = + chapter.reviewCardCount === 0 && chapter.newCardCount === 0 && chapter.archivedCardCount === 0; + + function getURL(cardType) { + const url = `/cards/ch-${chapter.id}/${cardType}`; + const urlSearchParams = new URLSearchParams(); + if (priority) urlSearchParams.set('priority', priority); + if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType); + const urlSearchParamsSTR = urlSearchParams.toString(); + if (!urlSearchParamsSTR) return url; + return url + `?${urlSearchParamsSTR}`; + } + + return ( + +

+ شما فصل + {chapter.name} + از درس + {props.lessonName} + را انتخاب کرده اید. +

+ {noCards ? ( +
+

تمام فلش کارت های جدید و مروری این درس را مطالعه کرده اید.

+

مرورهای بعدی این درس در روزهای آینده فعال خواهد شد.

+
+ ) : ( +

+ با استفاده از دکمه‌های زیر می‌توانید به فلش کارت‌های مورد نظر خود دسترسی داشته باشید. +

+ )} +
+ } + footer={ +
+ {chapter.reviewCardCount > 0 && ( + + مروری + + {chapter.reviewCardCount} + + + )} + {chapter.newCardCount > 0 && ( + + جدید + + {chapter.newCardCount} + + + )} + {chapter.archivedCardCount > 0 && ( + + آرشیو شده + + {chapter.archivedCardCount} + + + )} + {noCards &&
+ } + /> + ); +} + +ChapterModal.propTypes = { + chapter: PropTypes.object, + lessonName: PropTypes.string, + priority: PropTypes.string, + rateType: PropTypes.string, + onClose: PropTypes.func.isRequired +}; + +export default ChapterModal; diff --git a/src/components/decks/container.js b/src/components/decks/container.js new file mode 100644 index 0000000..f472349 --- /dev/null +++ b/src/components/decks/container.js @@ -0,0 +1,27 @@ +import { useLocation } from 'react-router-dom'; +import StudentDecks from './student-decks'; +import Fetcher from '../shared/fetches/fetcher'; +import NoDeck from './no-deck'; +import { fetchStudentDecks } from '../../redux/actions/student-deck'; + +function DecksContainer() { + const location = useLocation(); + const searchPrams = new URLSearchParams(location.search); + const priority = searchPrams.get('priority'); + const rateType = searchPrams.get('rate-type'); + const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1; + + return ( + fetchStudentDecks(priority, minRate)} + stateSelector={state => state.studentDecks} + renderEmpty={() => } + > + {studentDecks => ( + + )} + + ); +} + +export default DecksContainer; diff --git a/src/components/decks/index.js b/src/components/decks/index.js new file mode 100644 index 0000000..f4bd853 --- /dev/null +++ b/src/components/decks/index.js @@ -0,0 +1,14 @@ +import MainLayout from '../layouts/main'; +import DecksContainer from './container'; +import DecksTab from './tab'; + +function DecksPage() { + return ( + + + + + ); +} + +export default DecksPage; diff --git a/src/components/decks/no-deck.js b/src/components/decks/no-deck.js new file mode 100644 index 0000000..37f438c --- /dev/null +++ b/src/components/decks/no-deck.js @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom'; +import Segment from '../shared/segment'; +import NavLink from '../shared/buttons/nav-link'; + +function NoDeck() { + return ( + +

داوطلب گرامی، شما هیچ بسته فلش کارت فعالی ندارید.

+

+ لطفا برای خرید بسته فلش کارت مورد نظر خود به + + {' '} + فروشگاه{' '} + + مراجعه کنید. +

+

+ همچنین با مراجعه به فروشگاه می‌توانید از + بسته‌های تستی رایگان + مدمشاور نیز استفاده کنید. +

+ +
+ ); +} + +export default NoDeck; diff --git a/src/components/decks/reset-modal/index.js b/src/components/decks/reset-modal/index.js new file mode 100644 index 0000000..17851e8 --- /dev/null +++ b/src/components/decks/reset-modal/index.js @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import ResetModalStepOne from './step-one'; +import ResetModalStepTwo from './step-two'; +import ResetModalStepSuccess from './step-success'; +import { connect } from 'react-redux'; +import { createResetRequest } from '../../../redux/actions/reset-request'; +import { fetchStudentDecks } from '../../../redux/actions/student-deck'; +import ResetModalStepError from './step-error'; + +function ResetModal(props) { + const [step, setStep] = useState(1); + const [error, setError] = useState(null); + const { itemType, itemId, itemName, onClose } = props; + + const itemLabel = `${_[itemType]} ${itemName}`; + + if (error) { + return ( + { + setStep(1); + onClose(); + }} + /> + ); + } + + if (step === 1) { + return setStep(2)} />; + } + + if (step === 2) { + return ( + { + try { + await props.createResetRequest(itemType, itemId); + setStep(0); + } catch (error) { + setError(error); + } + }} + /> + ); + } + + if (step === 0) { + return ( + { + setStep(1); + props.fetchStudentDecks(); + onClose(); + }} + /> + ); + } + + return null; +} + +const _ = { chapter: 'فصل', lesson: 'درس', deck: 'بسته' }; + +ResetModal.propTypes = { + itemType: PropTypes.oneOf(['chapter', 'lesson', 'deck']).isRequired, + itemId: PropTypes.number.isRequired, + itemName: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + createResetRequest: PropTypes.func.isRequired, + fetchStudentDecks: PropTypes.func.isRequired +}; + +export default connect(null, { createResetRequest, fetchStudentDecks })(ResetModal); diff --git a/src/components/decks/reset-modal/step-error.js b/src/components/decks/reset-modal/step-error.js new file mode 100644 index 0000000..886f42c --- /dev/null +++ b/src/components/decks/reset-modal/step-error.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import Modal from '../../shared/modal'; +import Button from '../../shared/buttons/button'; + +function ResetModalStepError(props) { + return ( + بازنشانی فلش کارت‌ها} + body={ +
+

+ بازنشانی فلش کارت های + {props.itemLabel} + با + خطا + مواجه شد. +

+

در صورت تکرار لطفا ادمین سایت را در جریان قرار دهید.

+
+

+ کد خطا: + {props.error.status} +

+

+ اطلاعات خطا: + {JSON.stringify(props.error.data)} +

+
+
+ } + footer={ +
+
+ } + /> + ); +} + +ResetModalStepError.propTypes = { + itemLabel: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default ResetModalStepError; diff --git a/src/components/decks/reset-modal/step-one.js b/src/components/decks/reset-modal/step-one.js new file mode 100644 index 0000000..8afe96c --- /dev/null +++ b/src/components/decks/reset-modal/step-one.js @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import Modal from '../../shared/modal'; +import Button from '../../shared/buttons/button'; +import { InformationCircleIcon } from '@heroicons/react/outline'; +import Audio from '../../shared/audio'; + +function ResetModalStepOne(props) { + const [showGuide, setShowGuide] = useState(false); + + function toggleShowGuide() { + setShowGuide(showGuide => !showGuide); + } + + return ( + بازنشانی فلش کارت‌ها} + body={ +
+

+ شما در حال + بازنشانی + فلش کارت های + {props.itemLabel} + هستید. +

+

در صورت تایید تمام سوابق مرور فلش کارت های این مبحث حذف خواهد شد.

+

+ همچنین امتیازاتی که از مطالعه فلش کارت های این بخش کسب شده از بین خواهد رفت و ممکن از + رتبه شما در رتبه بندی هفتگی و ماهانه تغییر کند. +

+

آیا مطمئن هستید؟

+ {showGuide && ( + <> +
+
+
+ + )} +
+ } + footer={ +
+ +
+
+
+ } + /> + ); +} + +ResetModalStepOne.propTypes = { + itemLabel: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onNext: PropTypes.func.isRequired +}; + +export default ResetModalStepOne; diff --git a/src/components/decks/reset-modal/step-success.js b/src/components/decks/reset-modal/step-success.js new file mode 100644 index 0000000..bcc62e5 --- /dev/null +++ b/src/components/decks/reset-modal/step-success.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import Modal from '../../shared/modal'; +import Button from '../../shared/buttons/button'; + +function ResetModalStepSuccess(props) { + return ( + بازنشانی فلش کارت‌ها} + body={ +
+

+ بازنشانی فلش کارت های + {props.itemLabel} + با + موفقیت + انجام شد. +

+
+ } + footer={ +
+
+ } + /> + ); +} + +ResetModalStepSuccess.propTypes = { + itemLabel: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default ResetModalStepSuccess; diff --git a/src/components/decks/reset-modal/step-two.js b/src/components/decks/reset-modal/step-two.js new file mode 100644 index 0000000..1c9edaa --- /dev/null +++ b/src/components/decks/reset-modal/step-two.js @@ -0,0 +1,71 @@ +import { useRef, useState } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Modal from '../../shared/modal'; +import Button from '../../shared/buttons/button'; +import { Field, Form, Formik } from 'formik'; +import FormInput from '../../shared/forms/input'; + +function ResetModalStepTwo(props) { + const [isCorrectCode, setIsCorrectCode] = useState(false); + const formRef = useRef(null); + + return ( + بازنشانی فلش کارت‌ها} + body={ +
+

برای تایید 4 رقم آخر شماره تلفن همراه خود را وارد نمایید:

+ { + const isCorrect = code === props.correctCode; + setIsCorrectCode(isCorrect); + if (code.length !== 4) { + return { code: '4 رقم آخر شماره تلفن همراه خود را وارد نمایید.' }; + } + if (!isCorrect) return { code: 'کد وارد شده اشتباه است.' }; + }} + onSubmit={props.onNext} + > +
+ + +
+
+ } + footer={ +
+
+ } + /> + ); +} + +ResetModalStepTwo.propTypes = { + onClose: PropTypes.func.isRequired, + onNext: PropTypes.func.isRequired, + correctCode: PropTypes.string.isRequired +}; + +function mapStateToProps({ user }) { + return { correctCode: user.data.mobile.slice(-4) }; +} + +export default connect(mapStateToProps)(ResetModalStepTwo); diff --git a/src/components/decks/student-deck/container.js b/src/components/decks/student-deck/container.js new file mode 100644 index 0000000..5cd4ae0 --- /dev/null +++ b/src/components/decks/student-deck/container.js @@ -0,0 +1,50 @@ +import Fetcher from '../../shared/fetches/fetcher'; +import PropTypes from 'prop-types'; +import StudentDeck from './student-deck'; +import Loader from '../../shared/loader'; +import { useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { fetchStudentDeck } from '../../../redux/actions/student-deck'; + +function StudentDeckContainer({ studentDeckId, onSelectChapter, onRefreshSelected }) { + const location = useLocation(); + const urlSearchParams = new URLSearchParams(location.search); + const rateType = urlSearchParams.get('rate-type'); + const priority = urlSearchParams.get('priority'); + + const action = useCallback(() => { + const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1; + return fetchStudentDeck(studentDeckId, priority, minRate); + }, [studentDeckId, priority, rateType]); + + const renderLoader = useCallback( + () => ( +
+ +
+ ), + [] + ); + + return ( + state.studentDeck} renderLoader={renderLoader}> + {studentDeck => ( + + )} + + ); +} + +StudentDeckContainer.propTypes = { + studentDeckId: PropTypes.number.isRequired, + onSelectChapter: PropTypes.func.isRequired, + onRefreshSelected: PropTypes.func.isRequired +}; + +export default StudentDeckContainer; diff --git a/src/components/decks/student-deck/student-deck.js b/src/components/decks/student-deck/student-deck.js new file mode 100644 index 0000000..5f39757 --- /dev/null +++ b/src/components/decks/student-deck/student-deck.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Badge from '../badge'; +import Button from '../../shared/buttons/button'; +import { RefreshIcon } from '@heroicons/react/outline'; +import { compareChapter } from '../utils'; + +function StudentDeck({ priority, rateType, studentDeck, onSelectChapter, onRefreshSelected }) { + function getURL(chapterId, cardType) { + const url = `/cards/ch-${chapterId}/${cardType}`; + const urlSearchParams = new URLSearchParams(); + if (priority) urlSearchParams.set('priority', priority); + if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType); + const urlSearchParamsSTR = urlSearchParams.toString(); + if (!urlSearchParamsSTR) return url; + return url + `?${urlSearchParamsSTR}`; + } + + return studentDeck.deck.chapters.sort(compareChapter).map((chapter, index, array) => ( +
{ + e.stopPropagation(); + onSelectChapter(chapter); + }} + className={classNames('border border-t-0 border-gray-500 text-center', { + 'rounded-b-lg': index === array.length - 1 + })} + > +
+
({index + 1})
+
{chapter.name}
+
+ + +
+ {!rateType && ( +
+ +
+ )} +
+
+ )); +} + +StudentDeck.propTypes = { + priority: PropTypes.string, + rateType: PropTypes.string, + studentDeck: PropTypes.object.isRequired, + onSelectChapter: PropTypes.func.isRequired, + onRefreshSelected: PropTypes.func.isRequired +}; + +export default StudentDeck; diff --git a/src/components/decks/student-decks.js b/src/components/decks/student-decks.js new file mode 100644 index 0000000..81bbba5 --- /dev/null +++ b/src/components/decks/student-decks.js @@ -0,0 +1,167 @@ +import { Component, Fragment } from 'react'; +import { withRouter } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + InformationCircleIcon, + MinusCircleIcon, + PlusCircleIcon, + RefreshIcon +} from '@heroicons/react/outline'; +import { getStudentDeckComparator } from './utils'; +import Badge from './badge'; +import Button from '../shared/buttons/button'; +import ChapterModal from './chapter-modal'; +import ResetModal from './reset-modal'; +import StudentDeckContainer from './student-deck/container'; + +class StudentDecks extends Component { + constructor(props) { + super(props); + this.state = { + selectedStudentDeck: props.studentDecks.length === 1 ? props.studentDecks[0] : null, + selectedChapter: null, + selectedRefreshItem: null + }; + } + + isSelectedStudentDeck = studentDeckId => this.state.selectedStudentDeck?.id === studentDeckId; + + handleSelectStudentDeck = studentDeck => + this.setState(state => ({ + selectedStudentDeck: state.selectedStudentDeck?.id === studentDeck.id ? null : studentDeck + })); + + handleSelectChapter = chapter => + this.setState(state => ({ + selectedChapter: state.selectedChapter?.id === chapter?.id ? null : chapter + })); + + handleRefreshSelected = item => + this.setState(state => ({ + selectedRefreshItem: state.selectedRefreshItem?.id === item?.id ? null : item + })); + + getStudentDeckColor = studentDeck => { + if (studentDeck.reviewCardCount !== 0) return 'red'; + return studentDeck.newCardCount !== 0 ? 'blue' : 'green'; + }; + + render() { + const comparator = getStudentDeckComparator(this.props.priority); + return ( + +
+ {this.props.studentDecks.sort(comparator).map(studentDeck => { + if (this.props.priority && !studentDeck.deck.prioritized) { + return ( +
+
+
+ +
+
+ {studentDeck.deck.name} +
+
+ به زودی +
+
+
+ ); + } + + return ( +
+
this.handleSelectStudentDeck(studentDeck)} + > +
+ {this.isSelectedStudentDeck(studentDeck.id) ? ( + + ) : ( + + )} +
+
+ {studentDeck.deck.name} +
+
+ + +
+ {!this.props.rateType && ( +
+ +
+ )} +
+ {this.isSelectedStudentDeck(studentDeck.id) && ( + + )} +
+ ); + })} +
+ {this.state.selectedChapter && ( + this.handleSelectChapter(null)} + /> + )} + {this.state.selectedRefreshItem && ( + this.handleRefreshSelected(null)} + /> + )} +
+ ); + } +} + +StudentDecks.propTypes = { + priority: PropTypes.string, + rateType: PropTypes.string, + studentDecks: PropTypes.array.isRequired, + history: PropTypes.object.isRequired +}; + +export default withRouter(StudentDecks); diff --git a/src/components/decks/tab/index.js b/src/components/decks/tab/index.js new file mode 100644 index 0000000..c99d2f4 --- /dev/null +++ b/src/components/decks/tab/index.js @@ -0,0 +1,13 @@ +import RateTypeTab from './rate-type-tab'; +import PeriorityTab from './priority-tab'; + +function DecksTab() { + return ( +
+ + +
+ ); +} + +export default DecksTab; diff --git a/src/components/decks/tab/priority-tab.js b/src/components/decks/tab/priority-tab.js new file mode 100644 index 0000000..0eb0f02 --- /dev/null +++ b/src/components/decks/tab/priority-tab.js @@ -0,0 +1,38 @@ +import { useLocation } from 'react-router-dom'; +import ButtonGroup from '../../shared/buttons/button-group'; +import NavLink from '../../shared/buttons/nav-link'; + +function PeriorityTab() { + const location = useLocation(); + const urlSearchParams = new URLSearchParams(location.search); + const currentPriority = urlSearchParams.get('priority'); + + function getLinkColor(priority) { + return currentPriority === priority ? 'rose' : 'gray'; + } + + function getNextURL(priority) { + if (priority) { + urlSearchParams.set('priority', priority); + } else { + urlSearchParams.delete('priority'); + } + let url = '/decks'; + const urlSearchParamsSTR = urlSearchParams.toString(); + if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`; + return url; + } + + return ( + + + ضروری + + + همه + + + ); +} + +export default PeriorityTab; diff --git a/src/components/decks/tab/rate-type-tab.js b/src/components/decks/tab/rate-type-tab.js new file mode 100644 index 0000000..023540b --- /dev/null +++ b/src/components/decks/tab/rate-type-tab.js @@ -0,0 +1,41 @@ +import { useLocation } from 'react-router-dom'; +import ButtonGroup from '../../shared/buttons/button-group'; +import NavLink from '../../shared/buttons/nav-link'; + +function RateTypeTab() { + const location = useLocation(); + const urlSearchParams = new URLSearchParams(location.search); + const currentRateType = urlSearchParams.get('rate-type'); + + function getLinkColor(rateType) { + return currentRateType === rateType ? 'blue' : 'gray'; + } + + function getNextURL(rateType) { + if (rateType) { + urlSearchParams.set('rate-type', rateType); + } else { + urlSearchParams.delete('rate-type'); + } + let url = '/decks'; + const urlSearchParamsSTR = urlSearchParams.toString(); + if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`; + return url; + } + + return ( + + + ستاره دار + + + فعال + + + همه + + + ); +} + +export default RateTypeTab; diff --git a/src/components/decks/utils.js b/src/components/decks/utils.js new file mode 100644 index 0000000..02ac325 --- /dev/null +++ b/src/components/decks/utils.js @@ -0,0 +1,18 @@ +export function getStudentDeckComparator(priority = null) { + return function compareStudentDeck(sd1, sd2) { + return ( + (priority && sd2.deck.prioritized - sd1.deck.prioritized) || + sd2.reviewCardCount - sd1.reviewCardCount || + sd2.newCardCount - sd1.newCardCount || + sd2.deck.cardCount - sd1.deck.cardCount + ); + }; +} + +export function compareChapter(ch1, ch2) { + return ( + ch2.reviewCardCount - ch1.reviewCardCount || + ch2.newCardCount - ch1.newCardCount || + ch2.cardCount - ch1.cardCount + ); +} diff --git a/src/components/default/index.js b/src/components/default/index.js new file mode 100644 index 0000000..30b38f3 --- /dev/null +++ b/src/components/default/index.js @@ -0,0 +1,7 @@ +import MainLayout from '../layouts/main'; + +function DefaultPage() { + return ; +} + +export default DefaultPage; diff --git a/src/components/forget-password/index.js b/src/components/forget-password/index.js new file mode 100644 index 0000000..4940bec --- /dev/null +++ b/src/components/forget-password/index.js @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { Redirect } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { useToasts } from 'react-toast-notifications'; +import PropTypes from 'prop-types'; +import MainLayout from '../layouts/main'; +import MobileForm from './mobile-form'; +import SMSKeyForm from '../shared/sms-key-form'; +import PasswordForm from './password-form'; +import Alert from '../shared/alert'; +import { + checkSMSKeyRequest, + forgetPasswordRequest, + resetPasswordRequest +} from '../../redux/actions/user'; +import { loginUser } from '../../redux/actions/auth'; +import { selectIsAuthenticated } from '../../redux/selectors/auth'; +import { DEFAULT_DECKS_ROUTE } from '../utils/index'; + +function ForgetPasswordPage(props) { + const [step, setStep] = useState(0); + const [mobile, setMobile] = useState(null); + const [uid, setUID] = useState(null); + const [token, setToken] = useState(null); + const [error, setError] = useState(null); + + const { addToast } = useToasts(); + if (props.isAuthenticated) return ; + + function renderForm(step) { + if (step === MOBILE) { + return ( + + ); + } + + if (step === SMS_KEY) { + return ( + + ); + } + + if (step === PASSWORD) { + return ( + + ); + } + + return null; + } + + return ( + + {error ? ( + + ) : ( +
{renderForm(step)}
+ )} +
+ ); +} + +const [MOBILE, SMS_KEY, PASSWORD] = [0, 1, 2]; + +ForgetPasswordPage.propTypes = { + isAuthenticated: PropTypes.bool.isRequired, + checkSMSKeyRequest: PropTypes.func.isRequired, + forgetPasswordRequest: PropTypes.func.isRequired, + resetPasswordRequest: PropTypes.func.isRequired, + loginUser: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { isAuthenticated: selectIsAuthenticated(state) }; +} + +export default connect(mapStateToProps, { + checkSMSKeyRequest, + forgetPasswordRequest, + resetPasswordRequest, + loginUser +})(ForgetPasswordPage); diff --git a/src/components/forget-password/mobile-form.js b/src/components/forget-password/mobile-form.js new file mode 100644 index 0000000..aad60bf --- /dev/null +++ b/src/components/forget-password/mobile-form.js @@ -0,0 +1,58 @@ +import { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Field, Form, Formik } from 'formik'; +import Segment from '../shared/segment'; +import Divider from '../shared/divider'; +import Alert from '../shared/alert'; +import Button from '../shared/buttons/button'; +import FormInput from '../shared/forms/input'; +import { mobile, required } from '../shared/forms/validations'; + +function MobileForm(props) { + async function submit({ mobile }, { setErrors }) { + try { + await props.onSubmit(mobile); + } catch (error) { + setErrors({ _error: error.data.detail || error.data }); + } + } + + return ( + + + + {({ errors, dirty, isSubmitting }) => ( + + {dirty && errors._error && } +
+ +
+ شماره تلفن همراه وارد شده هنگام ثبت نام را وارد کنید. +
+ + + + بعدی + +
+ + ); +} + +Footer.propTypes = { + disabledNext: PropTypes.bool.isRequired, + showArchive: PropTypes.bool.isRequired, + archiveStudentCard: PropTypes.func.isRequired, + isPermanentArchive: PropTypes.bool.isRequired +}; + +function mapStateToProps({ review }) { + return { isPermanentArchive: !!review.data.studentCard && !!review.data.studentCard.status }; +} + +export default connect(mapStateToProps, { archiveStudentCard })(Footer); diff --git a/src/components/last-review/index.js b/src/components/last-review/index.js new file mode 100644 index 0000000..dde019b --- /dev/null +++ b/src/components/last-review/index.js @@ -0,0 +1,12 @@ +import MainLayout from '../layouts/main'; +import LastReviewContainer from './container'; + +function LastReviewPage() { + return ( + + + + ); +} + +export default LastReviewPage; diff --git a/src/components/last-review/option-container.js b/src/components/last-review/option-container.js new file mode 100644 index 0000000..37bd724 --- /dev/null +++ b/src/components/last-review/option-container.js @@ -0,0 +1,29 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import Option from './option'; + +class OptionContainer extends Component { + render() { + const options = [...this.props.options, { id: 0, text: 'نمی دانم', isCorrect: false }]; + return ( +
+ {options.map((option, index) => ( +
+ ); + } +} + +OptionContainer.propTypes = { + cardId: PropTypes.number.isRequired, + options: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedOptionId: PropTypes.number.isRequired +}; + +export default OptionContainer; diff --git a/src/components/last-review/option.js b/src/components/last-review/option.js new file mode 100644 index 0000000..37aa600 --- /dev/null +++ b/src/components/last-review/option.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { CheckIcon, XIcon } from '@heroicons/react/solid'; +import Button from '../shared/buttons/button'; + +function Option(props) { + const { option, isSelectedOption } = props; + + const color = option.isCorrect + ? 'green' + : isSelectedOption && option.id + ? 'red' + : option.id + ? 'gray' + : 'yellow'; + + return ( + + ); +} + +Option.propTypes = { + option: PropTypes.object.isRequired, + index: PropTypes.number.isRequired, + isSelectedOption: PropTypes.bool.isRequired +}; + +export default Option; diff --git a/src/components/last-review/question.js b/src/components/last-review/question.js new file mode 100644 index 0000000..0d98e9d --- /dev/null +++ b/src/components/last-review/question.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import Segment from '../shared/segment'; +import { DEFAULT_COLOR } from './utils'; + +function Question(props) { + return ( + + سوال: +
+ + ); +} + +Question.defaultProps = { color: DEFAULT_COLOR }; + +Question.propTypes = { + question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired, + color: PropTypes.string.isRequired +}; + +export default Question; diff --git a/src/components/last-review/reviews.js b/src/components/last-review/reviews.js new file mode 100644 index 0000000..aeef82d --- /dev/null +++ b/src/components/last-review/reviews.js @@ -0,0 +1,69 @@ +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { CheckIcon, XIcon } from '@heroicons/react/solid'; +import Segment from '../shared/segment'; + +function Reviews(props) { + const { color, studentCard } = props; + return ( + +
+

+ شناسه فلش کارت: + {studentCard.card.id} +

+

+ مرور شماره: + {studentCard.reviewCount} +

+
+ نتایج: +
+ {studentCard.reviews.map(review => ( + + {!review.status ? ( + + ) : review.status === 1 ? ( + + ) : ( + + )} + + ))} +
+
+
+
+ ); +} + +Reviews.propTypes = { + studentCard: PropTypes.object.isRequired, + color: PropTypes.string.isRequired +}; + +function mapStateToProps({ lastReview }) { + return { studentCard: lastReview.data.studentCard }; +} + +export default connect(mapStateToProps)(Reviews); diff --git a/src/components/last-review/utils.js b/src/components/last-review/utils.js new file mode 100644 index 0000000..1945be7 --- /dev/null +++ b/src/components/last-review/utils.js @@ -0,0 +1,4 @@ +export const DEFAULT_COLOR = 'gray'; +export const CORRECT_COLOR = 'green'; +export const WRONG_COLOR = 'red'; +export const DONT_KNOW_COLOR = 'yellow'; diff --git a/src/components/layouts/main/auth-header.js b/src/components/layouts/main/auth-header.js new file mode 100644 index 0000000..1e3d21e --- /dev/null +++ b/src/components/layouts/main/auth-header.js @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { connect } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { logoutUser } from '../../../redux/actions/auth'; +import NavLink from '../../shared/buttons/nav-link'; +import { fetchStudent } from '../../../redux/actions/student'; +import ButtonGroup from '../../shared/buttons/button-group'; +import Button from '../../shared/buttons/button'; +import { fetchUserData } from '../../../redux/actions/user'; +import { isAnonymous, isAuthenticated as isAuthenticatedFn } from '../../../redux/utils/auth'; + +function AuthHeader(props) { + useEffect(() => props.fetchStudent(), []); + const history = useHistory(); + const nextURL = `${history.location.pathname}${history.location.search.replace('&', ';')}`; + + if (isAnonymous(props.authStatus)) { + return ( + + + + + ); + } + + const isAuthenticated = isAuthenticatedFn(props.authStatus); + + return ( + + + +
+
+ +
+ {links.map(link => ( +
+ + {link.name} + +
+ ))} + +
+ + + ); +} + +const links = [ + { id: 1, name: 'خانه', href: '/' }, + { id: 2, name: 'بسته‌ها', href: DEFAULT_DECKS_ROUTE }, + { id: 3, name: 'فروشگاه', href: '/store' }, + { id: 4, name: 'رتبه بندی', href: '/ranking' }, + { id: 5, name: 'نمودار', href: '/charts/weekly' } +]; + +export default Header; diff --git a/src/components/layouts/main/index.js b/src/components/layouts/main/index.js new file mode 100644 index 0000000..26a85fd --- /dev/null +++ b/src/components/layouts/main/index.js @@ -0,0 +1,102 @@ +import { Fragment, useEffect } from 'react'; +import { connect } from 'react-redux'; +import { Redirect, useLocation } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import Header from './header'; +import Footer from './footer'; +import Loader from '../../shared/loader'; +import ScrollToTop from '../../shared/buttons/scroll-to-top'; +import ConfirmModal from '../../shared/confirm-modal'; +import { updateStudentNotify } from '../../../redux/actions/student'; +import CreateStudentForm from './create-student-form'; +import { isAnonymous, isExactAuthenticated, isLoading, isStudent } from '../../../redux/utils/auth'; + +function MainLayout(props) { + const { title, description, loginRequired } = props; + + const location = useLocation(); + const currentURL = `${location.pathname}${location.search.replace('&', ';')}`; + + useEffect(() => { + if (title) document.title = title; + if (description) document.description = description; + }, [title, description]); + + if (loginRequired && isAnonymous(props.authStatus)) { + return ; + } + + return ( + +
+
+
{renderContent(props)}
+
+
+ + +

+ با توجه به فعال شدن امکان اطلاع رسانی پیامکی سامانه فلش کارت‌های مدمشاور، از این پس + فعال شدن پکیج‌های جدید و کدهای تخفیف، از طریق پیامک اطلاع رسانی خواهد شد. +

+

+ آیا مایل به دریافت + پیامک های اطلاع رسانی + سامانه فلش کارت‌های مدمشاور هستید؟ +

+
+ } + onConfirm={props.updateStudentNotify.bind(null, true)} + onCancel={props.updateStudentNotify.bind(null, false)} + /> + + ); +} + +function renderContent(props) { + const { authStatus, loginRequired } = props; + if (loginRequired && isLoading(authStatus)) { + return ( +
+ +
+ ); + } + if (loginRequired && isExactAuthenticated(authStatus)) { + return ( +
+ +
+ ); + } + if (!loginRequired || isStudent(authStatus)) return props.children; +} + +MainLayout.defaultProps = { + loginRequired: true, + withFooter: false +}; + +MainLayout.propTypes = { + title: PropTypes.string, + description: PropTypes.string, + loginRequired: PropTypes.bool.isRequired, + authStatus: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), + withFooter: PropTypes.bool.isRequired, + notify: PropTypes.bool, + updateStudentNotify: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { + authStatus: state.auth.status, + notify: state.student.data?.notify + }; +} + +export default connect(mapStateToProps, { updateStudentNotify })(MainLayout); diff --git a/src/components/login/form.js b/src/components/login/form.js new file mode 100644 index 0000000..ab4587e --- /dev/null +++ b/src/components/login/form.js @@ -0,0 +1,68 @@ +import { Fragment } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +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 { loginUser } from '../../redux/actions/auth'; +import Alert from '../shared/alert'; +import NavLink from '../shared/buttons/nav-link'; +import ButtonGroup from '../shared/buttons/button-group'; + +function LoginForm(props) { + async function submit({ mobile, password }, { setErrors }) { + try { + await props.loginUser(mobile, password); + } catch (error) { + const errorMessage = error.message || 'شماره تلفن یا رمز عبور نادرست است.'; + setErrors({ _error: errorMessage }); + } + } + + return ( + + + {({ errors, dirty }) => ( + + {dirty && errors._error && } + +
+ + + + + ); +} + +BackLink.defaultProps = { + content: 'بازگشت', + 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; diff --git a/src/components/shared/buttons/button-group.css b/src/components/shared/buttons/button-group.css new file mode 100644 index 0000000..ff8d239 --- /dev/null +++ b/src/components/shared/buttons/button-group.css @@ -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; +} diff --git a/src/components/shared/buttons/button-group.js b/src/components/shared/buttons/button-group.js new file mode 100644 index 0000000..71dd040 --- /dev/null +++ b/src/components/shared/buttons/button-group.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import './button-group.css'; + +function ButtonGroup(props) { + return ( +
+ {props.children} +
+ ); +} + +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; diff --git a/src/components/shared/buttons/button-group.min.css b/src/components/shared/buttons/button-group.min.css new file mode 100644 index 0000000..f2e6070 --- /dev/null +++ b/src/components/shared/buttons/button-group.min.css @@ -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} \ No newline at end of file diff --git a/src/components/shared/buttons/button.js b/src/components/shared/buttons/button.js new file mode 100644 index 0000000..5366c5c --- /dev/null +++ b/src/components/shared/buttons/button.js @@ -0,0 +1,118 @@ +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 ( + + {this.props.content !== undefined ? this.props.content : this.props.children} + {this.props.loading && ( + + + + + )} + + ); + } +} + +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; diff --git a/src/components/shared/buttons/filled-button.js b/src/components/shared/buttons/filled-button.js new file mode 100644 index 0000000..83a9f5f --- /dev/null +++ b/src/components/shared/buttons/filled-button.js @@ -0,0 +1,67 @@ +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 ( + + ); + } +} + +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, + form: PropTypes.string, + 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; diff --git a/src/components/shared/buttons/flat-button.js b/src/components/shared/buttons/flat-button.js new file mode 100644 index 0000000..2242896 --- /dev/null +++ b/src/components/shared/buttons/flat-button.js @@ -0,0 +1,62 @@ +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 ( + + ); + } +} + +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, + form: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), + disabled: PropTypes.bool.isRequired, + className: PropTypes.string +}; + +export default FlatButton; diff --git a/src/components/shared/buttons/nav-link.js b/src/components/shared/buttons/nav-link.js new file mode 100644 index 0000000..e6920b0 --- /dev/null +++ b/src/components/shared/buttons/nav-link.js @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Button from './button'; + +function NavLink(props) { + const content = ( + + ); + + if (props.disabled) return content; + return ( + + {content} + + ); +} + +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, + wrapperClassName: PropTypes.string +}; + +export default NavLink; diff --git a/src/components/shared/buttons/outline-button.js b/src/components/shared/buttons/outline-button.js new file mode 100644 index 0000000..9cc9db0 --- /dev/null +++ b/src/components/shared/buttons/outline-button.js @@ -0,0 +1,65 @@ +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 ( + + ); + } +} + +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, + form: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), + disabled: PropTypes.bool.isRequired, + className: PropTypes.string +}; + +export default OutlineButton; diff --git a/src/components/shared/buttons/scroll-to-top.js b/src/components/shared/buttons/scroll-to-top.js new file mode 100644 index 0000000..1141b5a --- /dev/null +++ b/src/components/shared/buttons/scroll-to-top.js @@ -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 ( + + ); + } +} + +ScrollToTop.defaultProps = { + withFooter: false +}; + +ScrollToTop.propTypes = { + withFooter: PropTypes.bool.isRequired +}; + +export default ScrollToTop; diff --git a/src/components/shared/buttons/utils.js b/src/components/shared/buttons/utils.js new file mode 100644 index 0000000..6769abb --- /dev/null +++ b/src/components/shared/buttons/utils.js @@ -0,0 +1 @@ +export const withoutWeightColor = ['inherit', 'current', 'transparent', 'black', 'white']; diff --git a/src/components/shared/confirm-modal.js b/src/components/shared/confirm-modal.js new file mode 100644 index 0000000..de401dc --- /dev/null +++ b/src/components/shared/confirm-modal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import Modal from './modal'; +import Button from './buttons/button'; + +function ConfirmModal(props) { + return ( + +