This commit is contained in:
2026-08-08 18:56:53 +03:30
commit bf5c561a24
276 changed files with 31671 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

69
.eslintrc.json Normal file
View File

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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
build/
.env*
# Local Claude Code config — settings.local.json holds per-machine permissions.
.claude/

16
.prettierrc.json Normal file
View File

@@ -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"
]
}

87
package.json Normal file
View File

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

6
postcss.config.js Normal file
View File

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

View File

@@ -0,0 +1,78 @@
/**
*
* Name: IRAN Sans-Serif Font
* Version: 5.0
* Author: Moslem Ebrahimi (moslemebrahimi.com)
* Created on: Dec 25, 2012
* Updated on: Sep 01, 2017
* Website: http://fontiran.com
* Copyright: Commercial/Proprietary Software
--------------------------------------------------------------------------------------
فونت های ایران سن سریف یک نرم افزار مالکیتی محسوب می شود. جهت آگاهی از قوانین استفاده از این فونت ها لطفا به وب سایت (فونت ایران دات کام) مراجعه نمایید
--------------------------------------------------------------------------------------
IRAN Sans-serif fonts are considered a proprietary software. To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com
--------------------------------------------------------------------------------------
This set of fonts are used in this project under the license: (GY3QV)
--------------------------------------------------------------------------------------
*
**/
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: 900;
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot');
src: url('../fonts/eot/IRANSansWeb(FaNum)_Black.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum)_Black.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum)_Black.ttf') format('truetype');
}
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: bold;
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot');
src: url('../fonts/eot/IRANSansWeb(FaNum)_Bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum)_Bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum)_Bold.ttf') format('truetype');
}
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: 500;
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot');
src: url('../fonts/eot/IRANSansWeb(FaNum)_Medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum)_Medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum)_Medium.ttf') format('truetype');
}
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: 300;
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot');
src: url('../fonts/eot/IRANSansWeb(FaNum)_Light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum)_Light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum)_Light.ttf') format('truetype');
}
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: 200;
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot');
src: url('../fonts/eot/IRANSansWeb(FaNum)_UltraLight.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum)_UltraLight.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum)_UltraLight.ttf') format('truetype');
}
@font-face {
font-family: IRANSans;
font-style: normal;
font-weight: normal;
src: url('../fonts/eot/IRANSansWeb(FaNum).eot');
src: url('../fonts/eot/IRANSansWeb(FaNum).eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('../fonts/woff2/IRANSansWeb(FaNum).woff2') format('woff2'), /* FF39+,Chrome36+, Opera24+*/
url('../fonts/woff/IRANSansWeb(FaNum).woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('../fonts/ttf/IRANSansWeb(FaNum).ttf') format('truetype');
}

View File

@@ -0,0 +1,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;
}

1
public/assets/css/style.min.css vendored Normal file
View File

@@ -0,0 +1 @@
@import url(./fontiran.css);@keyframes progressBar{0%,50.01%{width:0;background-color:transparent}49.99%{width:100%;background-color:#00b1da}to{width:100%;background-color:#fb8928}}body{font-weight:300;direction:rtl;margin:0}a,body,button,div,h1,h2,h3,h4,h5,h6,input,label,span,strong,text,textarea{font-family:IRANSans!important}.preloader-container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column}.meter{height:10px;width:172px;background:#f3efe6;direction:ltr;border-radius:5px;margin:12px 0}.progress{height:100%;display:block;border-radius:5px;animation:progressBar 1.2s ease-in-out infinite;animation-fill-mode:both}.leading-0{line-height:0}.plain-text{unicode-bidi:plaintext;unicode-bidi:-moz-plaintext}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
public/browserconfig.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="https://medmoshaver.com/icons/ms/icon-70.png"/>
<square144x144logo src="https://medmoshaver.com/icons/ms/icon-144.png"/>
<square150x150logo src="https://medmoshaver.com/icons/ms/icon-150.png"/>
<square310x310logo src="https://medmoshaver.com/icons/ms/icon-310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

158
public/index.html Normal file
View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang='fa'>
<head>
<meta charset='utf-8' />
<meta content='IE=edge' http-equiv='X-UA-Compatible'>
<meta
content='width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no'
name='viewport'
>
<meta title='پنل فلش کارت گروه مدمشاور'>
<meta
content='گروه مد مشاور متشکل از تعدادی از رتبه‌های برتر آزمون دستیاری می‌باشد
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه می‌دهد.'
name='description'
>
<title>پنل فلش کارت گروه مد مشاور</title>
<!-- Android -->
<meta content='#fafafa' name='theme-color'>
<meta content='yes' name='mobile-web-app-capable'>
<!-- iOS -->
<meta content='پنل فلش کارت گروه مد مشاور' name='apple-mobile-web-app-title'>
<meta content='yes' name='apple-mobile-web-app-capable'>
<meta content='default' name='apple-mobile-web-app-status-bar-style'>
<!-- Windows -->
<meta content='#fafafa' name='msapplication-navbutton-color'>
<meta content='#fafafa' name='msapplication-TileColor'>
<meta content='https://medmoshaver.com/icons/ms/icon-144.png' name='msapplication-TileImage'>
<meta content='%PUBLIC_URL%/browserconfig.xml' name='msapplication-config'>
<!-- Pinned Sites -->
<meta content='Application Name' name='application-name'>
<meta content='Tooltip Text' name='msapplication-tooltip'>
<meta content='/' name='msapplication-starturl'>
<!-- Tap highlighting -->
<meta content='no' name='msapplication-tap-highlight'>
<!-- UC Mobile Browser -->
<meta content='yes' name='full-screen'>
<meta content='application' name='browsermode'>
<!-- Disable night mode for this page -->
<meta content='enable/disable' name='nightmode'>
<!-- Fitscreen -->
<!-- <meta name="viewport" content="uc-fitscreen=yes"/>-->
<!-- Layout mode -->
<meta content='fitscreen/standard' name='layoutmode'>
<!-- imagemode - show image even in text only mode -->
<meta content='force' name='imagemode'>
<!-- Orientation -->
<meta content='portrait' name='screen-orientation'>
<!-- open graph markup -->
<meta content='website' property='og:type' />
<meta content='Med Moshaver' property='og:site_name' />
<meta content='https://flashcard.medmoshaver.com' property='og:url' />
<meta content='مد مشاور - مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی'
property='og:title' />
<meta content='https://medmoshaver.com/icons/open-graph.png'
property='og:image' />
<meta content='image/png' property='og:image:type' />
<meta content='1200' property='og:image:width' />
<meta content='630' property='og:image:height' />
<meta
content='گروه مد مشاور متشکل از تعدادی از رتبه‌های برتر آزمون دستیاری می‌باشد
که مشاوره تحصیلی در زمینه آزمون دستیاری و پیش کارورزی ارائه می‌دهد.'
property='og:description'
/>
<!-- Main Link Tags -->
<link href='https://medmoshaver.com/icons/favicon/favicon-16.png' rel='icon' sizes='16x16'
type='image/png'>
<link href='https://medmoshaver.com/icons/favicon/favicon-32.png' rel='icon' sizes='32x32'
type='image/png'>
<link href='https://medmoshaver.com/icons/favicon/favicon-48.png' rel='icon' sizes='48x48'
type='image/png'>
<link href='https://medmoshaver.com/icons/favicon/favicon-96.png' rel='icon' sizes='96x96'
type='image/png'>
<!-- iOS -->
<link href='https://medmoshaver.com/icons/iOS/icon.png' rel='apple-touch-icon'>
<link href='https://medmoshaver.com/icons/iOS/icon-76.png' rel='apple-touch-icon' sizes='76x76'>
<link href='https://medmoshaver.com/icons/iOS/icon-120.png' rel='apple-touch-icon'
sizes='120x120'>
<link href='https://medmoshaver.com/icons/iOS/icon-152.png' rel='apple-touch-icon'
sizes='152x152'>
<!-- Startup Image -->
<link href='https://medmoshaver.com/icons/iOS/icon-320x480.png' rel='apple-touch-startup-image'>
<!-- Pinned Tab -->
<link color='#fafafa' href='https://medmoshaver.com/icons/icon.svg' rel='mask-icon' size='any'>
<!-- Android -->
<link href='https://medmoshaver.com/icons/android/icon-192.png' rel='icon' sizes='192x192'>
<link href='https://medmoshaver.com/icons/android/icon-128.png' rel='icon' sizes='128x128'>
<!-- Others -->
<link href='https://medmoshaver.com/icons/favicon/favicon.ico' rel='shortcut icon'
type='image/x-icon'>
<!-- UC Browser -->
<link href='https://medmoshaver.com/icons/iOS/icon-57.png' rel='apple-touch-icon-precomposed'
sizes='57x57'>
<link href='https://medmoshaver.com/icons/iOS/icon-72.png' rel='apple-touch-icon' sizes='72x72'>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link href='%PUBLIC_URL%/manifest.json' rel='manifest' />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link href='%PUBLIC_URL%/assets/css/style.min.css' rel='stylesheet' />
</head>
<body dir='rtl'>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'>
<div class='preloader-container'>
<img
alt='logo'
class='preloader-content'
src='https://medmoshaver.com/icons/icon.svg'
width='168px'
height='168px'
/>
<div class='meter'>
<span class='progress'></span>
</div>
</div>
</div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

0
public/lib/.gitkeep Normal file
View File

83
public/manifest.json Normal file
View File

@@ -0,0 +1,83 @@
{
"short_name": "Medmoshaver FlashCard 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"
}

3
public/robots.txt Normal file
View File

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

Binary file not shown.

0
src/App.css Normal file
View File

75
src/App.js Normal file
View File

@@ -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 (
<Router basename={process.env.PUBLIC_URL}>
<Suspense fallback={<Loader />}>
<Switch>
<Route exact path='/' component={HomePage} />
<Route exact path='/login' component={LoginPage} />
<Route exact path='/register' component={RegisterPage} />
<Route
exact
path='/cards/ch-:chapterId/:cardType(review|new|archived)'
component={CardPage}
/>
<Route
exact
path='/reviews/last/ch-:chapterId/:type(review|new)'
component={LastReviewPage}
/>
<Route exact path='/cards/:cardId/preview' component={CheckCardPage} />
<Route exact path='/decks' component={DecksPage} />
<Route exact path='/store' component={StorePage} />
<Route exact path='/orders/:orderId' component={OrderPage} />
<Route exact path='/payments/:paymentId/verify' component={PaymentVerifyPage} />
<Route exact path='/forget-password' component={ForgetPasswordPage} />
<Route exact path='/ranking/:rankType?' component={RankingPage} />
<Route
exact
path='/top-student-scores/:rankType(weekly|monthly)/:number'
component={TopStudentScoresPage}
/>
<Route exact path='/charts/:scoreType(weekly|monthly)' component={ScoreChartPage} />
<Route exact path='/terms-and-conditions' component={TermsAndConditionsPage} />
<Route path='*' component={DefaultPage} />
</Switch>
</Suspense>
</Router>
);
}
export default App;

0
src/App.min.css vendored Normal file
View File

View File

@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
function Answer(props) {
return (
<Segment id='answer' color={props.color} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>جواب:</span>
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
</Segment>
);
}
Answer.propTypes = {
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired
};
export default Answer;

View File

@@ -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 (
<div className='mb-20'>
<Header chapter={props.chapter} lessonName={props.card.lesson.name} />
<Question
cardId={card.id}
question={card.question}
color={color}
rate={rate}
setRate={setRate}
/>
<OptionContainer
cardId={card.id}
options={card.options}
setColor={setColor}
rate={rate}
rateUpdated={rate !== initialRate}
/>
{color !== DEFAULT_COLOR && <Reviews color={color} />}
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
<Footer isAnyOptionSelected={color !== DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
</div>
);
}
Card.propTypes = {
card: PropTypes.object.isRequired,
chapter: PropTypes.object.isRequired,
updateStudentCardRate: PropTypes.func.isRequired
};
export default connect(null, { updateStudentCardRate })(Card);

View File

@@ -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 (
<MultiFetcher
actions={[
fetchNextCard.bind(null, chapterId, cardType, priority, minRate),
fetchChapter.bind(null, chapterId, priority, minRate)
]}
stateSelectors={{
card: state => state.card,
chapter: state => state.chapter
}}
renderError={error => <Error error={error} />}
>
{({ card, chapter }) => <Card card={card} chapter={chapter} />}
</MultiFetcher>
);
}
export default CardsContainer;

View File

@@ -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 (
<Segment>
<p className='my-4 text-center text-xl'>
<span>فلش کارت </span>
<span className='font-bold'>{'مروری '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-row justify-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'new':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'جدید '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'archived':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'آرشیو شده '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center gap-3'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
default:
return null;
}
}
return <ErrorComponent />;
}
function _(text) {
return !text ? '' : { starred: 'ستاره دار', active: 'فعال', high: 'ضروری ' }[text];
}
Error.propTypes = {
error: PropTypes.object.isRequired
};
export default Error;

View File

@@ -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 (
<Fragment>
<ConfirmModal
showModal={showConfirmModal}
content={
<p>
<span>آیا از </span>
<span className='font-medium'>{archiveText}</span>
<span> این فلش کارت مطمئن هستید؟</span>
</p>
}
onConfirm={async () => {
await props.archiveStudentCard();
history.push(nextURL);
}}
onCancel={() => setShowConfirmModal(false)}
/>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
}
>
{props.isAnyOptionSelected ? (
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
بازگشت
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</Button>
) : (
<NavLink to={previousURL} color='red' className='m-2'>
قبلی
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</NavLink>
)}
{props.showArchive && (
<Button
color={props.isPermanentArchive ? 'indigo' : 'blue'}
onClick={() => setShowConfirmModal(true)}
className='m-2'
>
{props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت'}
</Button>
)}
<NavLink
replace
to={nextURL}
color='green'
textColor='white'
disabled={!props.isAnyOptionSelected}
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
</div>
</Fragment>
);
}
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);

View File

@@ -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 (
<Segment textAlign='center' color={color[cardType]} backgroundColor={color[cardType]}>
<div className='flex flex-wrap items-center justify-around gap-3 xl:justify-between'>
<div>
<span>فلش کارتهای </span>
{priority && <span className='font-medium text-red-600'>{_[priority] + ' '}</span>}
<span className={`font-medium text-${color[cardType]}-600`}>{_[cardType]}</span>
<span> درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> بخش </span>
<span className='font-medium'>{props.chapter.name}</span>
</div>
<div className={`rounded-full bg-${color[cardType]}-500 py-1 px-2.5 text-white`}>
<span className='font-medium'>{props.chapter[`${cardType}CardCount`]}</span>
</div>
</div>
</Segment>
);
}
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;

View File

@@ -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 ? (
<OutlineLightBulbIcon
width={30}
onClick={onUpdate}
className='text-gray-300 hover:text-gray-400'
/>
) : (
<SolidLightBulbIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
);
}
HideIcon.propTypes = {
hide: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default HideIcon;

View File

@@ -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 (
<div className='flex items-center justify-center gap-2'>
{rate >= 0 && <StarIcon star={rate > 0} onUpdate={() => setRate(rate => 1 - rate)} />}
{rate <= 0 && <HideIcon hide={rate < 0} onUpdate={() => setRate(rate => -1 - rate)} />}
<ReportIcon cardId={cardId} />
</div>
);
}
Icons.propTypes = {
cardId: PropTypes.number.isRequired,
rate: PropTypes.number,
setRate: PropTypes.func.isRequired
};
export default Icons;

View File

@@ -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 (
<>
<ExclamationIcon
width={30}
onClick={() => setShowModal(!showModal)}
className='text-orange-400 hover:text-orange-500'
/>
{showModal && (
<ReportModal cardId={cardId} showModal={showModal} onClose={() => setShowModal(false)} />
)}
</>
);
}
ReportIcon.propTypes = {
cardId: PropTypes.number.isRequired
};
export default ReportIcon;

View File

@@ -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 ? (
<SolidStarIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
) : (
<OutlineStarIcon width={30} onClick={onUpdate} className='text-gray-300 hover:text-gray-400' />
);
}
StarIcon.propTypes = {
star: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default StarIcon;

View File

@@ -0,0 +1,12 @@
import MainLayout from '../layouts/main';
import CardsContainer from './container';
function CardPage() {
return (
<MainLayout withFooter>
<CardsContainer />
</MainLayout>
);
}
export default CardPage;

View File

@@ -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 (
<div id='options' className='space-y-3 px-4'>
{this.state.options.map((option, index) => (
<Option
key={option.id}
option={option}
index={index}
isAnyOptionSelected={this.state.selectedOptionId !== null}
isSelectedOption={this.state.selectedOptionId === option.id}
onSelect={this.handleSelect}
/>
))}
</div>
);
}
}
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);

View File

@@ -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 (
<Button
id={`option ${option.id}`}
outline={!isAnyOptionSelected}
color={color}
colorWeight={300}
borderWeight={2}
disabled={isAnyOptionSelected}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
onClick={() => props.onSelect(option.id)}
className={classNames('w-full', { [`border-2 border-${color}-300`]: isAnyOptionSelected })}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
{isSelectedOption &&
(!option.id ? (
<ExclamationIcon
width={25}
height={25}
className={`shrink-0 stroke-current text-yellow-600`}
/>
) : option.isCorrect ? (
<CheckIcon
width={25}
height={25}
className={`shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
))}
</Button>
);
}
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;

View File

@@ -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 (
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
<div>
<div className='mb-4 flex w-full items-center '>
<div className='flex-1 font-semibold'>سوال:</div>
<div className='col-span-1'>
<Icons cardId={props.cardId} rate={props.rate} setRate={props.setRate} />
</div>
</div>
<div dangerouslySetInnerHTML={{ __html: props.question }} />
</div>
</Segment>
);
}
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;

View File

@@ -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 (
<Modal
showModal={props.showModal}
onClose={props.onClose}
header='فرم گزارش خطا'
body={
<Formik initialValues={{ card: props.cardId, type: '', description: '' }} onSubmit={submit}>
<Form id='report-form' className='flex flex-col gap-3'>
{error && (
<Segment
color='red'
backgroundColor='red'
className='text-center font-medium text-red-500'
>
<p>{error}</p>
<p>لطفا مدتی بعد مجددا تلاش کنید.</p>
</Segment>
)}
<input type='hidden' id='card' name='card' />
<Field
required
name='type'
label='نوع خطا'
component={FormSelect}
validate={required}
options={types}
/>
<Field
required
name='description'
label='توضیحات'
component={FormTextArea}
validate={required}
rows={5}
/>
</Form>
</Formik>
}
footer={
<div className='mx-3 flex justify-between gap-2'>
<Button
type='submit'
form='report-form'
color='green'
content='ثبت گزارش'
className='grow'
/>
<Button color='red' content='بازگشت' onClick={props.onClose} className='grow' />
</div>
}
/>
);
}
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);

View File

@@ -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 (
<Segment id='history' color={color} colorWeight={300} borderWeight={2}>
<div className='flex flex-wrap justify-around gap-4'>
<p>
<span className='font-medium'>شناسه فلش کارت: </span>
{studentCard.card}
</p>
{studentCard.status > 0 && (
<p>
<span className='font-medium'>وضعیت: </span>
{statuses[studentCard.status]}
</p>
)}
<p>
<span className='font-medium'>مرور شماره: </span>
{studentCard.reviewCount}
</p>
<div className='flex flex-wrap justify-center gap-x-2 gap-y-4'>
<span className='font-medium'>نتایج: </span>
<div>
{studentCard.reviews.map(review => (
<span key={review.id} className='px-px'>
{!review.status ? (
<ExclamationIcon
width={25}
height={25}
className={`inline-flex shrink-0 stroke-current text-yellow-500`}
/>
) : review.status === 1 ? (
<CheckIcon
width={25}
height={25}
className={`inline-flex shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`inline-flex shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
)}
</span>
))}
</div>
</div>
</div>
</Segment>
);
}
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);

View File

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

View File

@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import { DEFAULT_COLOR } from './utils';
function Answer(props) {
return (
<Segment id='answer' color={DEFAULT_COLOR} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>جواب:</span>
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
</Segment>
);
}
Answer.propTypes = {
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired
};
export default Answer;

View File

@@ -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 (
<div className='mb-20'>
<Header
cardId={card.id}
chapterName={props.card.chapter.name}
lessonName={props.card.lesson.name}
/>
<Question question={card.question} />
<OptionContainer cardId={card.id} options={card.options} />
<Answer answer={card.answer} />
<Footer />
</div>
);
}
Card.propTypes = {
card: PropTypes.object.isRequired
};
export default Card;

View File

@@ -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 (
<Fragment>
<Fetcher action={fetchCard.bind(null, cardId)} stateSelector={state => state.card}>
{card => <Card card={card} />}
</Fetcher>
<Footer />
</Fragment>
);
}
export default CardsContainer;

View File

@@ -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 (
<Fragment>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row justify-between bg-black bg-opacity-60'
}
>
<NavLink
replace
to={`/cards/${parseInt(cardId) + 1}/preview`}
color='green'
textColor='white'
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
{cardId > 1 && (
<NavLink
replace
to={`/cards/${parseInt(cardId) - 1}/preview`}
color='red'
className='m-2'
>
قبلی
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</NavLink>
)}
</div>
</Fragment>
);
}
Footer.propTypes = {
fetchCard: PropTypes.func.isRequired
};
export default connect(null, { fetchCard })(Footer);

View File

@@ -0,0 +1,26 @@
import Segment from '../shared/segment';
import PropTypes from 'prop-types';
function Header(props) {
return (
<Segment textAlign='center' color='violet' backgroundColor='violet'>
<p>
<span className='font-medium'>پیش نمایش </span>
<span>فلش کارت </span>
<span className='font-medium'>{props.cardId}</span>
<span> درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> بخش </span>
<span className='font-medium'>{props.chapterName}</span>
</p>
</Segment>
);
}
Header.propTypes = {
cardId: PropTypes.number.isRequired,
lessonName: PropTypes.string.isRequired,
chapterName: PropTypes.object.isRequired
};
export default Header;

View File

@@ -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 <MainLayout withFooter>{isSuperuser ? <CardsContainer /> : null}</MainLayout>;
}
export default CardPage;

View File

@@ -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 (
<div id='options' className='space-y-3 px-4'>
{this.state.options.map((option, index) => (
<Option key={option.id} option={option} index={index} />
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default OptionContainer;

View File

@@ -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 (
<Button
id={`option ${option.id}`}
disabled
outline={false}
color={color}
colorWeight={300}
borderWeight={2}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
className={`w-full border-2 border-${color}-300`}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
</Button>
);
}
Option.propTypes = {
option: PropTypes.object.isRequired,
index: PropTypes.number.isRequired
};
export default Option;

View File

@@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import { DEFAULT_COLOR } from './utils';
function Question(props) {
return (
<Segment id='question' color={DEFAULT_COLOR} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>سوال:</span>
<div dangerouslySetInnerHTML={{ __html: props.question }} />
</Segment>
);
}
Question.defaultProps = { color: DEFAULT_COLOR };
Question.propTypes = {
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired
};
export default Question;

View File

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

View File

@@ -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 = (
<div
className={classNames(
`bg-${color}-500 flex items-center rounded-xl px-1 py-0.5 leading-none text-white`,
`border border-opacity-50 border-${color}-600`,
{ 'cursor-not-allowed': !props.to }
)}
>
<span className='m-1 text-xs font-medium'>{props.text}</span>
<span className={`bg-white text-xs text-${color}-700 m-1 rounded-xl py-0.5 px-1`}>
{props.number}
</span>
</div>
);
if (!props.to) return content;
return <Link to={props.to}>{content}</Link>;
}
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;

View File

@@ -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 (
<Modal
showModal
onClose={props.onClose}
header='دسترسی به فلش کارت‌ها'
body={
<div className='text-base'>
<p>
<span>شما فصل </span>
<span className='font-medium'>{chapter.name}</span>
<span> از درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> را انتخاب کرده اید.</span>
</p>
{noCards ? (
<div>
<p>تمام فلش کارت های جدید و مروری این درس را مطالعه کرده اید.</p>
<p>مرورهای بعدی این درس در روزهای آینده فعال خواهد شد.</p>
</div>
) : (
<p>
با استفاده از دکمههای زیر میتوانید به فلش کارتهای مورد نظر خود دسترسی داشته باشید.
</p>
)}
</div>
}
footer={
<div className='flex flex-wrap justify-end gap-2'>
{chapter.reviewCardCount > 0 && (
<NavLink to={getURL('review')} size='sm' color='red'>
<span className='m-1 text-xs font-medium'>مروری</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-red-800'>
{chapter.reviewCardCount}
</span>
</NavLink>
)}
{chapter.newCardCount > 0 && (
<NavLink to={getURL('new')} size='sm' color='yellow'>
<span className='align-middle text-xs font-medium'>جدید</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-yellow-800'>
{chapter.newCardCount}
</span>
</NavLink>
)}
{chapter.archivedCardCount > 0 && (
<NavLink to={getURL('archived')} size='sm' color='blue'>
<span className='text-xs font-medium'>آرشیو شده</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-blue-800'>
{chapter.archivedCardCount}
</span>
</NavLink>
)}
{noCards && <Button color='red' content='بازگشت' onClick={props.onClose} />}
</div>
}
/>
);
}
ChapterModal.propTypes = {
chapter: PropTypes.object,
lessonName: PropTypes.string,
priority: PropTypes.string,
rateType: PropTypes.string,
onClose: PropTypes.func.isRequired
};
export default ChapterModal;

View File

@@ -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 (
<Fetcher
action={() => fetchStudentDecks(priority, minRate)}
stateSelector={state => state.studentDecks}
renderEmpty={() => <NoDeck />}
>
{studentDecks => (
<StudentDecks priority={priority} rateType={rateType} studentDecks={studentDecks} />
)}
</Fetcher>
);
}
export default DecksContainer;

View File

@@ -0,0 +1,14 @@
import MainLayout from '../layouts/main';
import DecksContainer from './container';
import DecksTab from './tab';
function DecksPage() {
return (
<MainLayout>
<DecksTab />
<DecksContainer />
</MainLayout>
);
}
export default DecksPage;

View File

@@ -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 (
<Segment className='leading-8'>
<p className='text-center'>داوطلب گرامی، شما هیچ بسته فلش کارت فعالی ندارید.</p>
<p className='text-center'>
لطفا برای خرید بسته فلش کارت مورد نظر خود به
<Link to='/store' className='text-blue-600'>
{' '}
فروشگاه{' '}
</Link>
مراجعه کنید.
</p>
<p className='text-center'>
<span>همچنین با مراجعه به فروشگاه میتوانید از </span>
<span className='font-medium text-pink-500'>بستههای تستی رایگان</span>
<span> مدمشاور نیز استفاده کنید.</span>
</p>
<NavLink to='/store' color='green' content='فروشگاه' className='mx-auto my-4' />
</Segment>
);
}
export default NoDeck;

View File

@@ -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 (
<ResetModalStepError
itemLabel={itemLabel}
error={error}
onClose={() => {
setStep(1);
onClose();
}}
/>
);
}
if (step === 1) {
return <ResetModalStepOne itemLabel={itemLabel} onClose={onClose} onNext={() => setStep(2)} />;
}
if (step === 2) {
return (
<ResetModalStepTwo
onClose={onClose}
onNext={async () => {
try {
await props.createResetRequest(itemType, itemId);
setStep(0);
} catch (error) {
setError(error);
}
}}
/>
);
}
if (step === 0) {
return (
<ResetModalStepSuccess
itemLabel={itemLabel}
onClose={() => {
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);

View File

@@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import Modal from '../../shared/modal';
import Button from '../../shared/buttons/button';
function ResetModalStepError(props) {
return (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>
<span>بازنشانی فلش کارت های </span>
<span className='font-semibold'>{props.itemLabel}</span>
<span> با </span>
<span className='font-medium text-red-500'>خطا</span>
<span> مواجه شد.</span>
</p>
<p>در صورت تکرار لطفا ادمین سایت را در جریان قرار دهید.</p>
<div className='my-2'>
<p>
<span className='font-semibold'>کد خطا: </span>
<span>{props.error.status}</span>
</p>
<p>
<span className='font-semibold'>اطلاعات خطا: </span>
<span>{JSON.stringify(props.error.data)}</span>
</p>
</div>
</div>
}
footer={
<div className='flex justify-end'>
<Button color='red' content='بازگشت' onClick={props.onClose} />
</div>
}
/>
);
}
ResetModalStepError.propTypes = {
itemLabel: PropTypes.string.isRequired,
error: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired
};
export default ResetModalStepError;

View File

@@ -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 (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>
<span>شما در حال </span>
<span className='font-semibold text-red-500'>بازنشانی </span>
<span>فلش کارت های </span>
<span className='font-semibold'>{props.itemLabel}</span>
<span> هستید.</span>
</p>
<p>در صورت تایید تمام سوابق مرور فلش کارت های این مبحث حذف خواهد شد.</p>
<p>
همچنین امتیازاتی که از مطالعه فلش کارت های این بخش کسب شده از بین خواهد رفت و ممکن از
رتبه شما در رتبه بندی هفتگی و ماهانه تغییر کند.
</p>
<p className='font-medium'>آیا مطمئن هستید؟</p>
{showGuide && (
<>
<hr className='my-4' />
<div id='guide'>
<Audio src='/voices/reset-guide.mp3' caption='فایل صوتی توضیحات بازنشانی (ریست):' />
</div>
</>
)}
</div>
}
footer={
<div className='flex justify-between'>
<Button circular color='gray' colorWeight={400} onClick={toggleShowGuide}>
<InformationCircleIcon width={20} height={20} />
</Button>
<div className='flex gap-1'>
<Button color='green' content='بلی' onClick={props.onNext} />
<Button color='red' content='خیر' onClick={props.onClose} />
</div>
</div>
}
/>
);
}
ResetModalStepOne.propTypes = {
itemLabel: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired
};
export default ResetModalStepOne;

View File

@@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import Modal from '../../shared/modal';
import Button from '../../shared/buttons/button';
function ResetModalStepSuccess(props) {
return (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>
<span>بازنشانی فلش کارت های </span>
<span className='font-semibold'>{props.itemLabel}</span>
<span> با </span>
<span className='font-medium text-green-500'>موفقیت</span>
<span> انجام شد.</span>
</p>
</div>
}
footer={
<div className='flex justify-end'>
<Button color='red' content='بازگشت' onClick={props.onClose} />
</div>
}
/>
);
}
ResetModalStepSuccess.propTypes = {
itemLabel: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired
};
export default ResetModalStepSuccess;

View File

@@ -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 (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>برای تایید 4 رقم آخر شماره تلفن همراه خود را وارد نمایید:</p>
<Formik
innerRef={formRef}
initialValues={{ code: '' }}
validate={({ code }) => {
const isCorrect = code === props.correctCode;
setIsCorrectCode(isCorrect);
if (code.length !== 4) {
return { code: '4 رقم آخر شماره تلفن همراه خود را وارد نمایید.' };
}
if (!isCorrect) return { code: 'کد وارد شده اشتباه است.' };
}}
onSubmit={props.onNext}
>
<Form className='grid grid-cols-1 gap-5 p-3'>
<Field
required
name='code'
label='4 رقم آخر شماره تلفن همراه'
component={FormInput}
maxLength={4}
/>
</Form>
</Formik>
</div>
}
footer={
<div className='flex justify-end gap-1'>
<Button
disabled={!isCorrectCode}
color='green'
content='تایید'
onClick={formRef.current?.submitForm}
/>
<Button color='red' content='بازگشت' onClick={props.onClose} />
</div>
}
/>
);
}
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);

View File

@@ -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(
() => (
<div className='border border-t-0 border-gray-500 text-center'>
<Loader full={false} />
</div>
),
[]
);
return (
<Fetcher action={action} stateSelector={state => state.studentDeck} renderLoader={renderLoader}>
{studentDeck => (
<StudentDeck
priority={priority}
rateType={rateType}
studentDeck={studentDeck}
onSelectChapter={onSelectChapter}
onRefreshSelected={onRefreshSelected}
/>
)}
</Fetcher>
);
}
StudentDeckContainer.propTypes = {
studentDeckId: PropTypes.number.isRequired,
onSelectChapter: PropTypes.func.isRequired,
onRefreshSelected: PropTypes.func.isRequired
};
export default StudentDeckContainer;

View File

@@ -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) => (
<div
key={chapter.id}
onClick={e => {
e.stopPropagation();
onSelectChapter(chapter);
}}
className={classNames('border border-t-0 border-gray-500 text-center', {
'rounded-b-lg': index === array.length - 1
})}
>
<div className='relative grid grid-flow-col grid-cols-9 p-4 hover:bg-gray-100 sm:grid-flow-row'>
<div className='row-span-3 m-auto'>({index + 1})</div>
<div className='col-span-8 m-auto sm:col-span-3'>{chapter.name}</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
<Badge
text='مروری'
number={chapter.reviewCardCount}
to={getURL(chapter.id, 'review')}
color='red'
/>
<Badge
text='جدید'
number={chapter.newCardCount}
to={getURL(chapter.id, 'new')}
color='yellow'
/>
</div>
{!rateType && (
<div className='row-span-3 m-auto font-bold'>
<Button
outline
circular
size='xs'
color='orange'
disabled={chapter.cardCount === chapter.newCardCount}
onClick={e => {
e.stopPropagation();
onRefreshSelected({ id: chapter.id, type: 'chapter', name: chapter.name });
}}
>
<RefreshIcon width={20} height={20} />
</Button>
</div>
)}
</div>
</div>
));
}
StudentDeck.propTypes = {
priority: PropTypes.string,
rateType: PropTypes.string,
studentDeck: PropTypes.object.isRequired,
onSelectChapter: PropTypes.func.isRequired,
onRefreshSelected: PropTypes.func.isRequired
};
export default StudentDeck;

View File

@@ -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 (
<Fragment>
<div className='mx-4 rounded-lg'>
{this.props.studentDecks.sort(comparator).map(studentDeck => {
if (this.props.priority && !studentDeck.deck.prioritized) {
return (
<div key={studentDeck.id} className='mb-1'>
<div
className={classNames(
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
`bg-gray-100`,
'rounded-lg border border-black p-4 text-center'
)}
>
<div className='row-span-3 m-auto'>
<InformationCircleIcon width={25} height={25} />
</div>
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
{studentDeck.deck.name}
</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
به زودی
</div>
</div>
</div>
);
}
return (
<div key={studentDeck.id} className='mb-1'>
<div
className={classNames(
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
`bg-${this.getStudentDeckColor(studentDeck)}-100`,
{ 'rounded-b-lg': !this.isSelectedStudentDeck(studentDeck.id) },
'rounded-t-lg border border-black p-4 text-center'
)}
onClick={() => this.handleSelectStudentDeck(studentDeck)}
>
<div className='row-span-3 m-auto'>
{this.isSelectedStudentDeck(studentDeck.id) ? (
<PlusCircleIcon width={25} height={25} />
) : (
<MinusCircleIcon width={25} height={25} />
)}
</div>
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
{studentDeck.deck.name}
</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
<Badge text='مروری' number={studentDeck.reviewCardCount} color='red' />
<Badge text='جدید' number={studentDeck.newCardCount} color='yellow' />
</div>
{!this.props.rateType && (
<div className='row-span-3 m-auto font-bold'>
<Button
outline
circular
size='xs'
color='orange'
disabled={studentDeck.deck.cardCount === studentDeck.newCardCount}
onClick={e => {
e.stopPropagation();
this.handleRefreshSelected({
id: studentDeck.deck.id,
type: 'deck',
name: studentDeck.deck.name
});
}}
>
<RefreshIcon width={20} height={20} />
</Button>
</div>
)}
</div>
{this.isSelectedStudentDeck(studentDeck.id) && (
<StudentDeckContainer
studentDeckId={studentDeck.id}
onSelectChapter={this.handleSelectChapter}
onRefreshSelected={this.handleRefreshSelected}
/>
)}
</div>
);
})}
</div>
{this.state.selectedChapter && (
<ChapterModal
chapter={this.state.selectedChapter}
lessonName={this.state.selectedStudentDeck?.deck.lesson.name}
priority={this.props.priority}
rateType={this.props.rateType}
onClose={() => this.handleSelectChapter(null)}
/>
)}
{this.state.selectedRefreshItem && (
<ResetModal
itemType={this.state.selectedRefreshItem.type}
itemId={this.state.selectedRefreshItem.id}
itemName={this.state.selectedRefreshItem.name}
onClose={() => this.handleRefreshSelected(null)}
/>
)}
</Fragment>
);
}
}
StudentDecks.propTypes = {
priority: PropTypes.string,
rateType: PropTypes.string,
studentDecks: PropTypes.array.isRequired,
history: PropTypes.object.isRequired
};
export default withRouter(StudentDecks);

View File

@@ -0,0 +1,13 @@
import RateTypeTab from './rate-type-tab';
import PeriorityTab from './priority-tab';
function DecksTab() {
return (
<div className='mb-8'>
<PeriorityTab />
<RateTypeTab />
</div>
);
}
export default DecksTab;

View File

@@ -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 (
<ButtonGroup className='mx-auto my-3 w-min'>
<NavLink to={getNextURL('high')} size='xl' color={getLinkColor('high')}>
ضروری
</NavLink>
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
همه
</NavLink>
</ButtonGroup>
);
}
export default PeriorityTab;

View File

@@ -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 (
<ButtonGroup className='mx-auto my-3 w-min'>
<NavLink to={getNextURL('starred')} size='xl' color={getLinkColor('starred')}>
ستاره دار
</NavLink>
<NavLink to={getNextURL('active')} size='xl' color={getLinkColor('active')}>
فعال
</NavLink>
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
همه
</NavLink>
</ButtonGroup>
);
}
export default RateTypeTab;

View File

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

View File

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

View File

@@ -0,0 +1,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 <Redirect to={DEFAULT_DECKS_ROUTE} />;
function renderForm(step) {
if (step === MOBILE) {
return (
<MobileForm
onSubmit={async function (mobile) {
const {
payload: { uid }
} = await props.forgetPasswordRequest(mobile);
setMobile(mobile);
setUID(uid);
setStep(SMS_KEY);
}}
/>
);
}
if (step === SMS_KEY) {
return (
<SMSKeyForm
back
mobile={mobile}
onSubmit={async function (smsKey) {
const {
payload: { token }
} = await props.checkSMSKeyRequest(uid, smsKey);
setToken(token);
setStep(PASSWORD);
}}
onResend={async function () {
const {
payload: { uid }
} = await props.forgetPasswordRequest(mobile);
setUID(uid);
}}
onBack={setStep.bind(null, MOBILE)}
onError={setError}
/>
);
}
if (step === PASSWORD) {
return (
<PasswordForm
onSubmit={async function (password) {
const { error } = await props.resetPasswordRequest(uid, token, password);
if (!error) {
addToast('رمز عبور شما با موفقیت تغییر کرد.', { appearance: 'success' });
await props.loginUser(mobile, password);
setStep(MOBILE);
}
}}
/>
);
}
return null;
}
return (
<MainLayout loginRequired={false} consultPackageRequired={false} packageRequired={false}>
{error ? (
<Alert content={error} className='mt-3 mb-5' />
) : (
<div className='mx-auto w-full sm:w-4/5 lg:w-3/5'>{renderForm(step)}</div>
)}
</MainLayout>
);
}
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);

View File

@@ -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 (
<Segment>
<Divider horizontal content='فرم فراموشی رمز عبور' />
<Formik initialValues={{ mobile: '' }} onSubmit={submit}>
{({ errors, dirty, isSubmitting }) => (
<Fragment>
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
<Form>
<Field
required
name='mobile'
label='شماره تلفن همراه'
type='tel'
placeholder='09121234567'
component={FormInput}
validate={[required, mobile]}
/>
<div className='text-medium my-4'>
شماره تلفن همراه وارد شده هنگام ثبت نام را وارد کنید.
</div>
<Button
type='submit'
disabled={!dirty || isSubmitting}
content='ادامه'
className='my-2 mr-auto'
/>
</Form>
</Fragment>
)}
</Formik>
</Segment>
);
}
MobileForm.propTypes = {
onSubmit: PropTypes.func.isRequired
};
export default MobileForm;

View File

@@ -0,0 +1,79 @@
import { Component, Fragment } from 'react';
import { Field, Form, Formik } from 'formik';
import PropTypes from 'prop-types';
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 { password, required } from '../shared/forms/validations';
class PasswordForm extends Component {
submit = async ({ password }, { setErrors }) => {
try {
await this.props.onSubmit(password);
} catch (error) {
const errorMessage = error.message || 'تغییر رمز عبور انجام نشد.';
setErrors({ _error: errorMessage });
}
};
validate = values => {
const errors = {};
if (values.password !== values.password2) errors.password2 = 'رمزهای عبور غیر یکسان هستند.';
return errors;
};
render() {
return (
<Segment>
<Divider horizontal content='فرم تغییر رمز عبور' className='mb-5' />
<Formik
initialValues={{ password: '', password2: '' }}
onSubmit={this.submit}
validate={this.validate}
>
{({ errors, dirty, isSubmitting }) => (
<Fragment>
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
<Form>
<Field
required
name='password'
type='password'
label='رمز عبور جدید'
component={FormInput}
validate={[required, password]}
className='my-4'
/>
<Field
required
name='password2'
type='password'
label='تکرار رمز عبور جدید'
placeholder='رمز عبور جدید خود را مجدد وارد نمایید'
component={FormInput}
validate={[required, password]}
/>
<Button
type='submit'
disabled={!dirty || isSubmitting}
content='تغییر رمز عبور'
className='my-2 mr-auto'
/>
</Form>
</Fragment>
)}
</Formik>
</Segment>
);
}
}
PasswordForm.propTypes = {
onSubmit: PropTypes.func.isRequired
};
export default PasswordForm;

View File

@@ -0,0 +1,12 @@
import Segment from '../shared/segment';
function Home() {
return (
<Segment>
<p className='text-large'>پنل فلش کارت گروه مد مشاور</p>
<p>به پنل فلش کارت گروه مدمشاور خوش آمدید.</p>
</Segment>
);
}
export default Home;

View File

@@ -0,0 +1,12 @@
import MainLayout from '../layouts/main';
import Home from './home';
function HomePage() {
return (
<MainLayout loginRequired={false} consultPackageRequired={false} packageRequired={false}>
<Home />
</MainLayout>
);
}
export default HomePage;

View File

@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
function Answer(props) {
return (
<Segment id='answer' color={props.color} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>جواب:</span>
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
</Segment>
);
}
Answer.propTypes = {
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired
};
export default Answer;

View File

@@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import OptionContainer from './option-container';
import Question from './question';
import Answer from './answer';
import Reviews from './reviews';
import Footer from './footer';
import { CORRECT_COLOR, DEFAULT_COLOR } from './utils';
function Card(props) {
const { card, color } = props;
return (
<div className='mb-20'>
<Question question={card.question} color={color} />
<OptionContainer
cardId={card.id}
options={card.options}
selectedOptionId={props.selectedOptionId}
/>
{color !== DEFAULT_COLOR && <Reviews color={color} />}
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
<Footer disabledNext={color === DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
</div>
);
}
Card.defaultProps = {
color: DEFAULT_COLOR
};
Card.propTypes = {
card: PropTypes.object.isRequired,
selectedOptionId: PropTypes.number.isRequired,
color: PropTypes.string.isRequired
};
export default Card;

View File

@@ -0,0 +1,32 @@
import { useParams } from 'react-router-dom';
import Card from './card';
import Fetcher from '../shared/fetches/fetcher';
import { fetchLastReview } from '../../redux/actions/review';
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
function LastReviewContainer() {
const { chapterId } = useParams();
return (
<Fetcher
action={fetchLastReview.bind(null, chapterId)}
stateSelector={state => state.lastReview}
>
{review => {
const color = review.option
? review.status === 1
? CORRECT_COLOR
: WRONG_COLOR
: DONT_KNOW_COLOR;
return (
<Card
card={review.studentCard.card}
selectedOptionId={review.option || 0}
color={color}
/>
);
}}
</Fetcher>
);
}
export default LastReviewContainer;

View File

@@ -0,0 +1,58 @@
import { Fragment } 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 NavLink from '../shared/buttons/nav-link';
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
function Footer(props) {
const history = useHistory();
const { chapterId, type } = useParams();
let nextURL = `/cards/ch-${chapterId}/${type}`;
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
if (urlSearchParamsSTR) {
nextURL += `?${urlSearchParamsSTR}`;
}
const { disabledNext } = props;
return (
<Fragment>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
}
>
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
بازگشت
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</Button>
<NavLink
to={nextURL}
color='green'
textColor='white'
disabled={disabledNext}
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
</div>
</Fragment>
);
}
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);

View File

@@ -0,0 +1,12 @@
import MainLayout from '../layouts/main';
import LastReviewContainer from './container';
function LastReviewPage() {
return (
<MainLayout withFooter>
<LastReviewContainer />
</MainLayout>
);
}
export default LastReviewPage;

View File

@@ -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 (
<div id='options' className='space-y-3 px-4'>
{options.map((option, index) => (
<Option
key={option.id}
option={option}
index={index}
isSelectedOption={this.props.selectedOptionId === option.id}
/>
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedOptionId: PropTypes.number.isRequired
};
export default OptionContainer;

View File

@@ -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 (
<Button
disabled
id={`option ${option.id}`}
color={color}
colorWeight={300}
borderWeight={2}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
className={`w-full border-2 border-${color}-300}`}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
{isSelectedOption &&
(!option.id ? (
<ExclamationIcon
width={25}
height={25}
className={`shrink-0 stroke-current text-yellow-600`}
/>
) : option.isCorrect ? (
<CheckIcon
width={25}
height={25}
className={`shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
))}
</Button>
);
}
Option.propTypes = {
option: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
isSelectedOption: PropTypes.bool.isRequired
};
export default Option;

View File

@@ -0,0 +1,21 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import { DEFAULT_COLOR } from './utils';
function Question(props) {
return (
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>سوال:</span>
<div dangerouslySetInnerHTML={{ __html: props.question }} />
</Segment>
);
}
Question.defaultProps = { color: DEFAULT_COLOR };
Question.propTypes = {
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired
};
export default Question;

Some files were not shown because too many files have changed in this diff Show More