This commit is contained in:
2026-08-08 19:05:08 +03:30
commit 30bd9fc01c
102 changed files with 14450 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

99
.eslintrc.json Normal file
View File

@@ -0,0 +1,99 @@
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": [
"plugin:react/recommended",
"standard"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"parser": "@babel/eslint-parser",
"plugins": [
"react",
"react-hooks"
],
"rules": {
"react/react-in-jsx-scope": "off",
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"semi": [
"error",
"always"
],
"no-console": [
"error",
{
"allow": [
"warn",
"error"
]
}
],
"jsx-quotes": [
"error",
"prefer-single"
],
"space-before-function-paren": [
"error",
{
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}
],
"curly": [
"error",
"multi-or-nest",
"consistent"
],
"object-curly-spacing": [
"error",
"never"
],
"multiline-ternary": "off",
"operator-linebreak": [
"error",
"after"
],
"no-unused-vars": [
"error",
{
"ignoreRestSiblings": true
}
],
"lines-between-class-members": [
"error",
"always",
{
"exceptAfterSingleLine": true
}
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

8
.gitignore vendored Normal file
View File

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

13
.prettierrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"trailingComma": "none",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"bracketSpacing": false,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100
}

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "bi_panel",
"version": "0.1.0",
"private": true,
"dependencies": {
"@heroicons/react": "^1.0.5",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0",
"chart.js": "^3.7.1",
"chartjs-adapter-date-fns": "^2.0.0",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"date-fns-jalali": "^2.28.0-0",
"formik": "^2.2.9",
"moment-jalaali": "^0.9.4",
"prop-types": "^15.8.0",
"react": "^17.0.2",
"react-chartjs-2": "^4.0.1",
"react-dom": "^17.0.2",
"react-query": "^3.34.7",
"react-redux": "^7.2.6",
"react-router-dom": "^5.3.0",
"react-scripts": "^5.0.0",
"react-toast-notifications": "^2.5.1",
"redux": "^4.1.2",
"redux-thunk": "^2.4.1",
"web-vitals": "^2.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/eslint-parser": "^7.16.5",
"@tailwindcss/forms": "^0.4.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.6",
"autoprefixer": "^10.4.0",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"eslint": "^8.5.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jest": "^25.3.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"faker": "^5.5.3",
"postcss": "^8.4.5",
"react-test-renderer": "^17.0.2",
"redux-devtools-extension": "^2.13.9",
"tailwindcss": "^3.0.8",
"tailwindcss-rtl": "^0.8.0"
}
}

6
postcss.config.js Normal file
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,66 @@
@import url(./fontiran.css);
body {
font-family: IRANSans !important;
font-weight: 300;
direction: rtl;
margin: 0;
}
h1, h2, h3, h4, h5, h6, text, strong, input, textarea {
font-family: IRANSans !important;
}
div, span, a, label, button {
font-family: IRANSans !important;
}
.preloader-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
flex-direction: column;
}
.meter {
height: 10px;
width: 172px;
background: #f3efe6;
direction: ltr;
border-radius: 5px;
margin: 12px 0;
}
.progress {
height: 100%;
display: block;
border-radius: 5px;
animation: progressBar 1.2s ease-in-out infinite;
animation-fill-mode: both;
}
@keyframes progressBar {
0% {
width: 0;
background-color: transparent;
}
49.99% {
width: 100%;
background-color: #00B1DA;
}
50.01% {
width: 0;
background-color: transparent;
}
100% {
width: 100%;
background-color: #FB8928
}
}
.leading-0 {
line-height: 0;
}

1
public/assets/css/style.min.css vendored Normal file
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}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
public/browserconfig.xml Normal file
View File

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

157
public/index.html Normal file
View File

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

0
public/lib/.gitkeep Normal file
View File

83
public/manifest.json Normal file
View File

@@ -0,0 +1,83 @@
{
"short_name": "Medmoshaver FlashCard BI Panel",
"name": "Medmoshaver FlashCard BI Panel",
"icons": [
{
"src": "https://medmoshaver.com/icons/pwa/icon-36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-256.png",
"sizes": "256x256",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-384.png",
"sizes": "384x384",
"type": "image/png",
"density": "8.0",
"purpose": "maskable any"
},
{
"src": "https://medmoshaver.com/icons/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#fafafa",
"background_color": "#fafafa"
}

3
public/robots.txt Normal file
View File

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

0
src/App.css Normal file
View File

26
src/App.js Normal file
View File

@@ -0,0 +1,26 @@
import React from 'react';
import {BrowserRouter as Router, Route, Switch} from 'react-router-dom';
import moment from 'moment-jalaali';
import HomePage from './components/home';
import DefaultPage from './components/default';
import LoginPage from './components/login';
import OrdersPage from './components/orders';
import StudentsCountPage from './components/students-count';
function App() {
return (
<Router basename={process.env.PUBLIC_URL}>
<Switch>
<Route exact path='/' component={HomePage} />
<Route exact path='/login' component={LoginPage} />
<Route exact path='/orders' component={OrdersPage} />
<Route exact path='/students-count' component={StudentsCountPage} />
<Route path='*' component={DefaultPage} />
</Switch>
</Router>
);
}
moment.loadPersian({usePersianDigits: false, dialect: 'persian-modern'});
export default App;

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

42
src/api/auth.js Normal file
View File

@@ -0,0 +1,42 @@
import {request} from './index';
// local storage keys
export const ACCESS_TOKEN_KEY = 'access_token';
export const REFRESH_TOKEN_KEY = 'refresh_token';
export async function getToken() {
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
if (accessToken) return accessToken;
return await getFreshToken();
}
export async function getFreshToken() {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) throw Error('Refresh token not found.');
const data = await fetchFreshToken(refreshToken);
localStorage.setItem(ACCESS_TOKEN_KEY, data.access);
return data.access;
}
export async function fetchToken({mobile, password}) {
return await request({
method: 'POST',
url: '/auth/login',
data: {mobile, password}
});
}
export async function fetchFreshToken(token) {
return await request({
method: 'POST',
url: '/auth/refresh',
data: {refresh: token}
});
}
export function logoutUser(queryClient, history = null) {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
queryClient.invalidateQueries('user');
if (history) history.push('/login');
}

28
src/api/index.js Normal file
View File

@@ -0,0 +1,28 @@
import axios from 'axios';
import {getFreshToken, getToken} from './auth';
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL,
headers: {}
});
export async function request(options) {
const {auth, ...requestOptions} = options;
if (auth) {
const accessToken = await getToken();
requestOptions.headers = {...requestOptions.headers, Authorization: `Bearer ${accessToken}`};
}
try {
return (await apiClient(requestOptions)).data;
} catch (error) {
if (!auth || error.response?.status !== 401) throw error.response;
const accessToken = await getFreshToken();
requestOptions.headers.Authorization = `Bearer ${accessToken}`;
try {
return (await apiClient(requestOptions)).data;
} catch (error) {
throw error.response;
}
}
}

5
src/api/order.js Normal file
View File

@@ -0,0 +1,5 @@
import {request} from './index';
export async function fetchOrderData() {
return await request({url: '/decks/ordered-decks/data', auth: true});
}

5
src/api/student.js Normal file
View File

@@ -0,0 +1,5 @@
import {request} from './index';
export async function fetchStudentsCountData() {
return await request({url: '/students/students-count-data', auth: true});
}

5
src/api/user.js Normal file
View File

@@ -0,0 +1,5 @@
import {request} from './index';
export async function fetchUser() {
return await request({url: '/users/user', auth: true});
}

View File

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

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,16 @@
import MainLayout from '../layouts/main';
import Home from './home';
function HomePage() {
return (
<MainLayout
loginRequired={false}
consultPackageRequired={false}
packageRequired={false}
>
<Home/>
</MainLayout>
);
}
export default HomePage;

View File

@@ -0,0 +1,53 @@
import {useHistory} from 'react-router-dom';
import {useQueryClient} from 'react-query';
import PropTypes from 'prop-types';
import NavLink from '../../shared/buttons/nav-link';
import ButtonGroup from '../../shared/buttons/button-group';
import Button from '../../shared/buttons/button';
import {useUser} from '../../../hooks/user';
import {logoutUser} from '../../../api/auth';
function AuthHeader(props) {
const history = useHistory();
const {status, data: user} = useUser();
const queryClient = useQueryClient();
const nextURL = `${history.location.pathname}${history.location.search.replace('&', ';')}`;
if (status === 'error') {
return (
<ButtonGroup className={props.className}>
<NavLink
to={`/login?next=${nextURL}`}
color='blue'
content='ورود'
className={props.className}
/>
</ButtonGroup>
);
}
return (
<ButtonGroup className={props.className}>
<NavLink
to='/profile'
content={user?.name || user?.mobile}
loading={status === 'loading'}
disabled={status === 'loading'}
/>
<Button
content='خروج'
color='red'
onClick={() => logoutUser(queryClient, history)}
loading={status === 'loading'}
disabled={status === 'loading'}
>
</Button>
</ButtonGroup>
);
}
AuthHeader.propTypes = {
className: PropTypes.string
};
export default AuthHeader;

View File

@@ -0,0 +1,7 @@
function Footer() {
return (
<footer/>
);
}
export default Footer;

View File

@@ -0,0 +1,78 @@
import {useState} from 'react';
import classNames from 'classnames';
import {MenuIcon} from '@heroicons/react/outline';
import {XIcon} from '@heroicons/react/solid';
import AuthHeader from './auth-header';
import NavLink from '../../shared/buttons/nav-link';
function Header() {
const [isHidden, setIsHidden] = useState(true);
const iconSize = 34;
return (
<header className='sticky top-0 z-50'>
<nav className='flex flex-wrap items-center justify-between bg-gray-800 p-2'>
<NavLink to='/' color='transparent' className='hidden md:block'>
<p className='text-lg font-bold'>Med Moshaver</p>
</NavLink>
<AuthHeader className='mx-2 md:order-last' />
<div className='flex md:hidden'>
<div className='flex md:hidden'>
<button id='hamburger' onClick={() => setIsHidden(isHidden => !isHidden)}>
<MenuIcon
width={iconSize}
height={iconSize}
className={classNames(
'toggle block',
{hidden: !isHidden},
'rounded-md border-2 border-blue-500 stroke-current p-1 text-blue-500'
)}
/>
<XIcon
width={iconSize}
height={iconSize}
className={classNames(
'toggle block',
{hidden: isHidden},
'rounded-md border-2 border-blue-500 stroke-current p-1 text-blue-500'
)}
/>
</button>
</div>
</div>
<div
className={classNames(
'toggle',
{hidden: isHidden},
'text-bold mt-5 w-full text-right md:mt-0 md:flex md:w-auto',
'border-t-2 border-blue-900 md:border-none'
)}
>
{links.map(link => (
<div
key={link.id}
className={`
w-100 block rounded-none border-t-2 border-blue-500
px-1 py-1 md:inline-block md:border-none
`}
>
<NavLink to={link.href} textSize='base' color='transparent'>
{link.name}
</NavLink>
</div>
))}
</div>
</nav>
</header>
);
}
const links = [
{id: 1, name: 'خانه', href: '/'},
{id: 2, name: 'فروش', href: '/orders'}
];
export default Header;

View File

@@ -0,0 +1,69 @@
import {Component, Fragment} from 'react';
import {withRouter, Redirect} from 'react-router-dom';
import PropTypes from 'prop-types';
import {UseUser} from '../../shared/use-user';
import Header from './header';
import Container from '../../shared/container';
import Footer from './footer';
import ScrollToTop from '../../shared/buttons/scroll-to-top';
import Loader from '../../shared/loader';
class MainLayout extends Component {
componentDidMount() {
const {title, description} = this.props;
if (title) document.title = title;
if (description) document.description = description;
}
renderContent = status => {
const {loginRequired} = this.props;
if (loginRequired && status === 'loading') return <div><Loader/></div>;
if (!loginRequired || status === 'success') return this.props.children;
};
render() {
const {location, loginRequired} = this.props;
const currentURL = `${location.pathname}${location.search.replace('&', ';')}`;
return (
<UseUser>
{({status}) => {
if (loginRequired && status === 'error') return <Redirect to={`/login?next=${currentURL}`}/>;
return (
<Fragment>
<div className='flex flex-col h-screen'>
<Header/>
<main className='flex-1 py-4'>
<Container>
{this.renderContent(status)}
</Container>
</main>
<Footer/>
</div>
<ScrollToTop/>
</Fragment>
);
}}
</UseUser>
);
}
}
MainLayout.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
loginRequired: PropTypes.bool.isRequired,
location: PropTypes.object.isRequired,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
])
};
MainLayout.defaultProps = {
loginRequired: true
};
export default withRouter(MainLayout);

View File

@@ -0,0 +1,64 @@
import {Fragment} from 'react';
import {Field, Form, Formik} from 'formik';
import Segment from '../shared/segment';
import {mobile, required} from '../shared/forms/validations';
import FormInput from '../shared/forms/input';
import Button from '../shared/buttons/button';
import Alert from '../shared/alert';
import ButtonGroup from '../shared/buttons/button-group';
import {useLoginMutation} from '../../hooks/auth';
function LoginForm() {
const {mutate} = useLoginMutation();
return (
<Segment>
<Formik
initialValues={{mobile: '', password: ''}}
onSubmit={submit.bind(null, mutate)}
>
{({errors, dirty}) => (
<Fragment>
{(dirty && errors._error) && (
<Alert content={errors._error} className='mt-3 mb-5'/>
)}
<Form className='grid grid-cols-1 gap-5 p-3'>
<Field
required
name='mobile'
label='شماره تلفن همراه'
type='tel'
placeholder='09121234567'
component={FormInput}
validate={mobile}
/>
<Field
required
name='password'
label='رمز عبور'
type='password'
component={FormInput}
validate={required}
/>
<ButtonGroup float>
<Button type='submit' color='green' content='ورود'/>
</ButtonGroup>
</Form>
</Fragment>
)}
</Formik>
</Segment>
);
}
async function submit(loginUser, data, {setErrors}) {
try {
await loginUser(data);
} catch (error) {
const errorMessage = error.message || 'شماره تلفن یا رمز عبور نادرست است.';
setErrors({_error: errorMessage});
}
}
export default LoginForm;

View File

@@ -0,0 +1,27 @@
import {Redirect} from 'react-router-dom';
import MainLayout from '../layouts/main';
import LoginForm from './form';
import useQueryParams from '../../hooks/query-params';
import {useUser} from '../../hooks/user';
function LoginPage() {
const {isAuthenticated} = useUser();
const {next} = useQueryParams();
if (isAuthenticated) {
const nextURL = next ? next.replace(';', '&') : '/orders';
return (<Redirect to={nextURL}/>);
}
return (
<MainLayout loginRequired={false}>
<div className='flex justify-center'>
<div className='w-full md:w-3/4 lg:w-2/3 xl:w-1/2 2xl:w-1/3'>
<LoginForm/>
</div>
</div>
</MainLayout>
);
}
export default LoginPage;

View File

@@ -0,0 +1,14 @@
import Orders from './orders';
import {useOrdersData} from '../../hooks/order';
import Loader from '../shared/loader';
import Error from '../shared/error';
function OrdersContainer() {
const {isLoading, isError, data, error} = useOrdersData();
if (isLoading) return <Loader />;
if (isError) return <Error error={error} />;
return <Orders orderItems={data} />;
}
export default OrdersContainer;

View File

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

View File

@@ -0,0 +1,197 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import moment from 'moment-jalaali';
class Orders extends Component {
state = {
showCount: true,
showTotalPrice: true,
showAuthorPrice: false,
showUnits: true
};
toggleShowCount = () => this.setState(state => ({showCount: !state.showCount}));
toggleShowTotalPrice = () => this.setState(state => ({showTotalPrice: !state.showTotalPrice}));
toggleShowAuthorPrice = () => this.setState(state => ({showAuthorPrice: !state.showAuthorPrice}));
toggleShowUnits = () => this.setState(state => ({showUnits: !state.showUnits}));
presentPrice = price => (this.state.showUnits ? `${price.toLocaleString()} ریال` : price);
presentCount = count => (this.state.showUnits ? `${count} عدد` : count);
getMonthkeys = () => {
const keys = [];
const today = moment();
let month = today.jMonth() + 1;
let year = today.jYear();
while (year > 1400 || month > 5) {
keys.push(`${year}-${month < 10 ? `0${month}` : month}`);
if (month > 1) {
month = month - 1;
} else {
month = 12;
year = year - 1;
}
}
return keys;
};
render() {
const {showCount, showTotalPrice, showAuthorPrice, showUnits} = this.state;
const monthKeys = this.getMonthkeys();
const dataByOrderItem = {};
for (const orderItem of this.props.orderItems) {
let price = 0;
let count = 0;
for (const monthKey in orderItem.data) {
if (monthKeys.includes(monthKey)) {
price += orderItem.data[monthKey].totalPrice;
count += orderItem.data[monthKey].count;
}
}
dataByOrderItem[orderItem.id] = {price, count};
}
const dataByMonthKey = {};
for (const monthKey of monthKeys) dataByMonthKey[monthKey] = {price: 0, count: 0};
for (const orderItem of this.props.orderItems) {
for (const monthKey in orderItem.data) {
if (monthKeys.includes(monthKey)) {
dataByMonthKey[monthKey].price += orderItem.data[monthKey]?.totalPrice || 0;
dataByMonthKey[monthKey].count += orderItem.data[monthKey]?.count || 0;
}
}
}
const totalData = {price: 0, count: 0};
for (const orderItemId in dataByOrderItem) {
totalData.price += dataByOrderItem[orderItemId].price;
totalData.count += dataByOrderItem[orderItemId].count;
}
return (
<div className='px-4 overflow-x-auto'>
<p className='font-medium text-lg'>جدول فروش ماهیانه:</p>
<div className='flex flex-wrap justify-center gap-8 my-2'>
<div>
<input
type='checkbox'
id='show-total-price'
checked={showTotalPrice}
onChange={this.toggleShowTotalPrice}
/>
<label htmlFor='show-total-price' className='mx-2'>
نمایش مبلغ کل
</label>
</div>
<div>
<input
type='checkbox'
id='show-count'
checked={showCount}
onChange={this.toggleShowCount}
/>
<label htmlFor='show-count' className='mx-2'>
نمایش تعداد
</label>
</div>
<div>
<input
type='checkbox'
id='show-author-price'
checked={showAuthorPrice}
onChange={this.toggleShowAuthorPrice}
/>
<label htmlFor='show-author-price' className='mx-2'>
نمایش سهم مولف
</label>
</div>
<div>
<input
type='checkbox'
id='show-units'
checked={showUnits}
onChange={this.toggleShowUnits}
/>
<label htmlFor='show-units' className='mx-2'>
نمایش واحدها
</label>
</div>
</div>
<table className='my-4 mx-auto'>
<thead className='border-collapse border border-gray-700'>
<tr className='text-center font-medium bg-indigo-50'>
<td className='border border-gray-700 p-3'>#</td>
{monthKeys.map(orderPaid => (
<td key={orderPaid} className='border border-gray-700 p-3'>
{orderPaid}
</td>
))}
<td className='border border-gray-700 bg-indigo-300 p-3'>مجموع</td>
</tr>
</thead>
<tbody>
{this.props.orderItems.map(orderItem => (
<tr key={orderItem.id} className='text-center'>
<td className='border border-gray-700 bg-indigo-50 font-medium p-3'>
{orderItem.name}
</td>
{monthKeys.map(monthKey => {
const orderPaidData = orderItem.data[monthKey];
return (
<td key={monthKey} className='border border-gray-700 p-3'>
{showTotalPrice && <p>{this.presentPrice(orderPaidData?.totalPrice || 0)}</p>}
{showCount && <p>{this.presentCount(orderPaidData?.count || 0)}</p>}
{showAuthorPrice && (
<p>{this.presentPrice((orderPaidData?.totalPrice || 0) / 5)}</p>
)}
</td>
);
})}
<td className='border border-gray-700 bg-indigo-50 p-3'>
{showTotalPrice && (
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price)}</p>
)}
{showCount && <p>{this.presentCount(dataByOrderItem[orderItem.id].count)}</p>}
{showAuthorPrice && (
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price / 5)}</p>
)}
</td>
</tr>
))}
<tr className='text-center bg-indigo-100'>
<td className='border border-green-700 bg-indigo-300 font-medium p-3'>مجموع</td>
{monthKeys.map(monthKey => {
return (
<td key={monthKey} className='border border-gray-700 p-3'>
{showTotalPrice && <p>{this.presentPrice(dataByMonthKey[monthKey].price)}</p>}
{showCount && <p>{this.presentCount(dataByMonthKey[monthKey].count)}</p>}
{showAuthorPrice && (
<p>{this.presentPrice(dataByMonthKey[monthKey].price / 5)}</p>
)}
</td>
);
})}
<td className='border border-gray-700 p-3 bg-indigo-300'>
{showTotalPrice && <p>{this.presentPrice(totalData.price)}</p>}
{showCount && <p>{this.presentPrice(totalData.count)}</p>}
{showAuthorPrice && <p>{this.presentPrice(totalData.price / 5)}</p>}
</td>
</tr>
</tbody>
</table>
</div>
);
}
}
Orders.propTypes = {
orderItems: PropTypes.array.isRequired
};
export default Orders;

View File

@@ -0,0 +1,24 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Alert(props) {
return (
<div
role='alert'
className={classNames(
'text-center text-lg font-medium',
'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative',
props.className
)}
>
{props.content}
</div>
);
}
Alert.propTypes = {
content: PropTypes.node.isRequired,
className: PropTypes.string
};
export default Alert;

View File

@@ -0,0 +1,50 @@
import {useHistory} from 'react-router-dom';
import PropTypes from 'prop-types';
import Button from './button';
function BackLink(props) {
const history = useHistory();
return (
<Button
color={props.color}
outline={props.outline}
flat={props.flat}
size={props.size}
loading={props.loading}
disabled={props.disabled}
className={props.className}
onClick={history.goBack}
>
{props.content || props.children}
</Button>
);
}
BackLink.defaultProps = {
outline: false,
flat: false,
size: 'base',
textSize: 'sm',
color: 'blue',
loading: false,
disabled: false
};
BackLink.propTypes = {
content: PropTypes.string,
color: PropTypes.string,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
outline: PropTypes.bool.isRequired,
flat: PropTypes.bool.isRequired,
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
loading: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default BackLink;

View File

@@ -0,0 +1,25 @@
div.btn-group.btn-group-stretched a,
div.btn-group.btn-group-stretched button {
width: 100%;
}
div.btn-group a,
div.btn-group button {
margin: 0;
}
div.btn-group button {
border-radius: 0;
}
div.btn-group > button:first-child,
div.btn-group > a:first-child > button:first-child {
border-top-right-radius: .5rem;
border-bottom-right-radius: .5rem;
}
div.btn-group > button:last-child,
div.btn-group > a:last-child > button:last-child {
border-top-left-radius: .5rem;
border-bottom-left-radius: .5rem;
}

View File

@@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import './button-group.css';
function ButtonGroup(props) {
return (
<div className={classNames(
'flex btn-group',
{'btn-group-stretched': props.float},
props.className
)}
>
{props.children}
</div>
);
}
ButtonGroup.defaultProps = {float: false};
ButtonGroup.propTypes = {
float: PropTypes.bool.isRequired,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
className: PropTypes.string
};
export default ButtonGroup;

View File

@@ -0,0 +1 @@
div.btn-group{margin:.5rem}div.btn-group>button{margin:0;border-radius:0}div.btn-group>button:first-child{border-top-right-radius:.5rem;border-bottom-right-radius:.5rem}div.btn-group>button:last-child{border-top-left-radius:.5rem;border-bottom-left-radius:.5rem}

View File

@@ -0,0 +1,120 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import FilledButton from './filled-button';
import FlatButton from './flat-button';
import OutlineButton from './outline-button';
class Button extends Component {
getSizeClass = () => {
const sizePaddingMap = {
default: {
zero: `p-0`,
xs: 'py-0.5 px-1',
sm: 'py-1 px-2',
base: 'py-1.5 px-3',
lg: 'py-2 px-4',
xl: 'py-2.5 px-5'
},
circular: {
zero: `p-0`,
xs: 'p-0.5',
sm: 'p-1',
base: 'p-1.5',
lg: 'p-2',
xl: 'p-2.5'
}
};
const {size, textSize, circular} = this.props;
return `text-${textSize} ${sizePaddingMap[circular ? 'circular' : 'default'][size]}`;
};
getContainer = () => {
if (this.props.flat) return FlatButton;
if (this.props.outline) return OutlineButton;
return FilledButton;
};
render() {
const Container = this.getContainer();
return (
<Container
{...this.props}
className={classNames(
this.getSizeClass(),
this.props.inline ? 'inline-flex' : 'flex',
`justify-${this.props.justifyContent} items-center`,
this.props.className
)}
>
{(this.props.content !== undefined) ? this.props.content : this.props.children}
{this.props.loading && (
<svg
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
/>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
/>
</svg>
)}
</Container>
);
}
}
Button.defaultProps = {
type: 'button',
inline: false,
outline: false,
flat: false,
size: 'base',
textSize: 'sm',
justifyContent: 'center',
color: 'blue',
colorWeight: 500,
borderWeight: 1,
loading: false,
disabled: false,
circular: false
};
Button.propTypes = {
content: PropTypes.string,
color: PropTypes.string.isRequired,
colorWeight: PropTypes.number.isRequired,
borderWeight: PropTypes.number.isRequired,
textColor: PropTypes.string,
textColorWeight: PropTypes.string,
type: PropTypes.string.isRequired,
onClick: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
inline: PropTypes.bool.isRequired,
outline: PropTypes.bool.isRequired,
flat: PropTypes.bool.isRequired,
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
justifyContent: PropTypes.oneOf(['start', 'center', 'end', 'between', 'around', 'evenly']).isRequired,
loading: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
circular: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default Button;

View File

@@ -0,0 +1,64 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withoutWeightColor} from './utils';
class FilledButton extends Component {
getColorClass = () => {
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
return classNames(
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
{'text-white': !textColor},
{'opacity-40 cursor-not-allowed': disabled},
withoutWeightColor.includes(color) ? `bg-${color}` : `bg-${color}-${colorWeight}`,
{[`hover:bg-${color}-${colorWeight + 100}`]: !withoutWeightColor.includes(color) && !disabled}
);
};
render() {
return (
<button
type={this.props.type}
disabled={this.props.disabled}
onClick={this.props.onClick}
className={classNames(
'focus:outline-none rounded-md',
this.getColorClass(),
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
this.props.className
)}
>
{this.props.children}
</button>
);
}
}
FilledButton.defaultProps = {
type: 'button',
color: 'blue',
colorWeight: 500,
borderWeight: 1,
loading: false,
disabled: false
};
FilledButton.propTypes = {
color: PropTypes.string.isRequired,
colorWeight: PropTypes.number.isRequired,
borderWeight: PropTypes.number.isRequired,
textColor: PropTypes.string,
textColorWeight: PropTypes.string,
type: PropTypes.string.isRequired,
onClick: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
loading: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default FilledButton;

View File

@@ -0,0 +1,61 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withoutWeightColor} from './utils';
class FlatButton extends Component {
getColorClass = () => {
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
return classNames(
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
{[`text-${color}-${colorWeight}`]: !textColor},
{'opacity-40 cursor-not-allowed': disabled},
{[`hover:bg-${color}-100`]: !withoutWeightColor.includes(color) && !disabled}
);
};
render() {
return (
<button
type={this.props.type}
disabled={this.props.disabled}
onClick={this.props.onClick}
className={classNames(
'focus:outline-none rounded-md',
this.getColorClass(),
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
this.props.className
)}
>
{this.props.children}
</button>
);
}
}
FlatButton.defaultProps = {
type: 'button',
color: 'blue',
colorWeight: 500,
borderWeight: 1,
disabled: false
};
FlatButton.propTypes = {
color: PropTypes.string.isRequired,
colorWeight: PropTypes.number.isRequired,
borderWeight: PropTypes.number.isRequired,
textColor: PropTypes.string,
textColorWeight: PropTypes.string,
type: PropTypes.string.isRequired,
onClick: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
disabled: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default FlatButton;

View File

@@ -0,0 +1,56 @@
import {Link} from 'react-router-dom';
import PropTypes from 'prop-types';
import Button from './button';
import classNames from 'classnames';
function NavLink(props) {
const content = (
<Button
color={props.color}
outline={props.outline}
flat={props.flat}
size={props.size}
textSize={props.textSize}
loading={props.loading}
disabled={props.disabled}
className={classNames(props.className, 'min-w-max')}
>
{props.content || props.children}
</Button>
);
if (props.disabled) return content;
return (
<Link to={props.to} replace={props.replace} className={props.className}>
{content}
</Link>
);
}
NavLink.defaultProps = {
outline: false,
flat: false,
size: 'base',
textSize: 'sm',
color: 'blue',
loading: false,
disabled: false,
replace: false
};
NavLink.propTypes = {
content: PropTypes.string,
to: PropTypes.string.isRequired,
color: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
outline: PropTypes.bool.isRequired,
flat: PropTypes.bool.isRequired,
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
loading: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
replace: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default NavLink;

View File

@@ -0,0 +1,64 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withoutWeightColor} from './utils';
class OutlineButton extends Component {
getColorClass = () => {
const {color, colorWeight, borderWeight, textColor, textColorWeight, disabled} = this.props;
return classNames(
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
{[`text-${color}-${colorWeight}`]: !textColor},
{'opacity-40 cursor-not-allowed': disabled},
(borderWeight === 1) ? 'border' : `border-${borderWeight}`,
withoutWeightColor.includes(color) ? `border-${color}` : `border-${color}-${colorWeight}`,
{[`hover:bg-${color}-50`]: !withoutWeightColor.includes(color) && !disabled}
);
};
render() {
return (
<button
type={this.props.type}
disabled={this.props.disabled}
onClick={this.props.onClick}
className={classNames(
'focus:outline-none rounded-md',
this.getColorClass(),
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
this.props.className
)}
>
{this.props.children}
</button>
);
}
}
OutlineButton.defaultProps = {
type: 'button',
color: 'blue',
colorWeight: 500,
borderWeight: 1,
loading: false,
disabled: false
};
OutlineButton.propTypes = {
color: PropTypes.string.isRequired,
colorWeight: PropTypes.number.isRequired,
borderWeight: PropTypes.number.isRequired,
textColor: PropTypes.string,
textColorWeight: PropTypes.string,
type: PropTypes.string.isRequired,
onClick: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
disabled: PropTypes.bool.isRequired,
className: PropTypes.string
};
export default OutlineButton;

View File

@@ -0,0 +1,54 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
import {ChevronUpIcon} from '@heroicons/react/solid';
import Button from './button';
class ScrollToTop extends Component {
constructor(props) {
super(props);
this.state = {isVisible: false};
}
componentDidMount() {
document.addEventListener('scroll', this.handleScrollEvent);
}
componentWillUnmount() {
document.removeEventListener('scroll', this.handleScrollEvent);
}
handleScrollEvent = () => {
this.setState({isVisible: window.pageYOffset > 300});
};
scrollToTop = () => {
window.scrollTo({top: 0, behavior: 'smooth'});
};
render() {
if (!this.state.isVisible) return null;
return (
<Button
circular
color='gray'
colorWeight={400}
size='lg'
onClick={this.scrollToTop}
className={`fixed bottom-${this.props.withFooter ? 16 : 5} left-5 rounded-full text-white`}
>
<ChevronUpIcon width={24} height={24}/>
</Button>
);
}
}
ScrollToTop.defaultProps = {
withFooter: false
};
ScrollToTop.propTypes = {
withFooter: PropTypes.bool.isRequired
};
export default ScrollToTop;

View File

@@ -0,0 +1 @@
export const withoutWeightColor = ['inherit', 'current', 'transparent', 'black', 'white'];

View File

@@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Container(props) {
return (
<div className={classNames('container m-auto', props.className)}>
{props.children}
</div>
);
}
Container.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
className: PropTypes.string
};
export default Container;

View File

@@ -0,0 +1,3 @@
.divider {
line-height: 0;
}

View File

@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import './divider.css';
function Divider(props) {
return (
<div className={classNames('divider', 'w-100 mx-0 my-6 border-b text-center', props.className)}>
{props.content && <span className='bg-white px-0'>{props.content}</span>}
</div>
);
}
Divider.propTypes = {
content: PropTypes.string,
className: PropTypes.string
};
export default Divider;

View File

@@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
function Error(props) {
const {error} = props;
return (
<div className='text-center'>
<p>خطایی رخ داده است.</p>
{error.status && (
<p>
<span>کد خطا: </span>
<span>{error.status}</span>
</p>
)}
{error.data && (
<p>{JSON.stringify(error.data)}</p>
)}
</div>
);
}
Error.propTypes = {
error: PropTypes.object
};
export default Error;

View File

@@ -0,0 +1,70 @@
import {useEffect} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import Loader from '../loader';
import Error from '../error';
import {fetchData} from '../../utils/fetch';
import {ERROR, INIT, LOADING} from '../../../redux/constants';
function Fetcher(props) {
useEffect(
() => fetchData(props.action),
[props.action]
);
// Loading
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
// Error
if (props.status === ERROR) {
const result = props.renderError(props.error);
if (result !== null) return result;
}
// Empty Data
if (
(Array.isArray(props.data) && props.data.length === 0) ||
(Object.entries(props.data).length === 0 && props.data.constructor === Object)
) {
const result = props.renderEmpty();
if (result !== null) return result;
}
// Good Condition
return props.children(props.data);
}
Fetcher.propTypes = {
action: PropTypes.func.isRequired,
stateSelector: PropTypes.func.isRequired,
status: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.object,
PropTypes.arrayOf(PropTypes.object)
]).isRequired,
renderEmpty: PropTypes.func.isRequired,
renderError: PropTypes.func.isRequired,
error: PropTypes.oneOfType(PropTypes.string, PropTypes.object),
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
PropTypes.func
])
};
Fetcher.defaultProps = {
renderEmpty: () => null,
// eslint-disable-next-line react/display-name
renderError: () => <Error/>
};
function mapStateToProps(state, {stateSelector}) {
return {
status: stateSelector(state).status,
error: stateSelector(state).error,
data: stateSelector(state).data
};
}
function mapDispatchToProps(dispatch, {action}) {
return bindActionCreators({action}, dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Fetcher);

View File

@@ -0,0 +1,83 @@
import {useEffect} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import Loader from '../loader';
import {fetchData, mergeStatuses} from '../../utils/fetch';
import {ERROR, INIT, LOADING} from '../../../redux/constants';
import Error from '../error';
function MultiFetcher(props) {
useEffect(
() => fetchData(props.action),
[props.action]
);
// Loading
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
// Error
if (props.status === ERROR) return props.renderError(props.errors);
// Empty Data
const emptyDataKeys = [];
for (const key in props.data) {
const data = props.data[key];
if (
(Array.isArray(data) && data.length === 0) ||
(Object.entries(data).length === 0 && data.constructor === Object)
) emptyDataKeys.push(key);
}
if (emptyDataKeys.length !== 0) {
const result = props.renderEmpty(emptyDataKeys);
if (result !== null) return result;
}
// Good Condition
return props.children(props.data);
}
MultiFetcher.propTypes = {
actions: PropTypes.arrayOf(PropTypes.func).isRequired,
action: PropTypes.func.isRequired,
stateSelectors: PropTypes.objectOf(PropTypes.func).isRequired,
status: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
renderEmpty: PropTypes.func.isRequired,
renderError: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
PropTypes.func
])
};
MultiFetcher.defaultProps = {
renderEmpty: () => null,
// eslint-disable-next-line react/display-name
renderError: () => <Error/>
};
function mapStateToProps(state, {stateSelectors}) {
const statuses = [];
const data = {};
const errors = {};
for (const key in stateSelectors) {
if (!(key in stateSelectors)) continue;
const selectedState = stateSelectors[key](state);
statuses.push(selectedState.status);
data[key] = selectedState.data;
if (selectedState.status === ERROR) errors[key] = selectedState.error;
}
return {status: mergeStatuses(statuses), data, errors};
}
function mapDispatchToProps(dispatch, {actions}) {
function action() {
return function (dispatch) {
for (let i = 0; i < actions.length; i++)
dispatch(actions[i]());
};
}
return bindActionCreators({action}, dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(MultiFetcher);

View File

@@ -0,0 +1,47 @@
/* eslint-disable react/prop-types */
import classNames from 'classnames';
function FormInput({field, form, inline, ...props}) {
const id = props.id || field.name;
const label = props.label || field.name;
const placeholder = props.placeholder || label;
const type = props.type || 'input';
const exactLabel = ['.', '?', '!', '؟'].includes(label.charAt(label.length - 1)) ?
label.substring(0, label.length - 1) :
label;
const error = form.errors[field.name];
const touched = form.touched[field.name];
return (
<div className={classNames(
{'inline ': inline},
props.className
)}
>
<label
htmlFor={id}
className={classNames(
inline ? 'block sm:inline sm:ml-4 my-2' : 'block mb-3',
'text-sm font-medium text-gray-700'
)}
>
{`${exactLabel} :`}
</label>
<input
{...props}
{...field}
id={id}
type={type}
placeholder={placeholder}
className={classNames(
'px-4 py-2 rounded-md border border-gray-300 shadow-sm sm:text-sm',
'focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none',
inline ? 'block sm:inline sm:ml-4 my-2' : 'block w-full'
)}
/>
{error && touched && <div className='mt-1 px-2 text-red-400 text-sm'>{error}</div>}
</div>
);
}
export default FormInput;

View File

@@ -0,0 +1,55 @@
// validation functions
export function required(value) {
return (value != null) ? undefined : 'وارد کردن این فیلد الزامی است.';
}
export function number(value) {
return (value != null) ? 'وارد کردن این فیلد الزامی است.' :
isNaN(Number(value)) ?
'لطفا یک عدد وارد نمایید.' :
undefined;
}
export function email(value) {
return !value ?
'وارد کردن این فیلد الزامی است.' :
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) ?
'لطفا یک ایمیل معتبر وارد نمایید.' :
undefined;
}
export function mobile(value) {
return !value ?
'وارد کردن این فیلد الزامی است.' :
!/^[0][9][0-9]{9}$/i.test(value) ?
'لطفا یک شماره تلفن همراه معتبر با حروف انگلیسی وارد نمایید.' :
undefined;
}
export function password(value) {
return !value ?
'وارد کردن این فیلد الزامی است.' :
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/i.test(value) ?
'لطفا یک رمزعبور دارای حداقل 8 کاراکتر و ترکیبی از حروف انگلیسی و اعداد وارد نمایید.' :
undefined;
}
export function smsKey(value) {
return !value ?
'وارد کردن این فیلد الزامی است.' :
!/^[0-9]{6}$/i.test(value) ?
'لطفا کد 6 رقمی پیامک شده به شماره خود را وارد نمایید.' :
undefined;
}
export function gender(value) {
return !['M', 'F'].includes(value) ? 'وارد کردن این فیلد الزامی است.' : undefined;
}
export function persian(value) {
return !value ?
'وارد کردن این فیلد الزامی است.' :
/[^آابپتثجچحخدذرزژسشصضطظعغفقکگلمنوهی ئء]/.test(value) ?
'لطفا از حروف فارسی استفاده نمایید.' :
undefined;
}

View File

@@ -0,0 +1,24 @@
import PropTypes from 'prop-types';
import {ReactComponent as Spinner} from './spinner.svg';
import './spinner.css';
function Loader(props) {
return (
<div className='flex flex-col justify-center items-center h-screen text-center'>
<Spinner width='70' className='spinner'/>
<div className='mt-5'>
{props.content}
</div>
</div>
);
}
Loader.defaultProps = {
content: 'لطفا منتظر بمانید.'
};
Loader.propTypes = {
content: PropTypes.string.isRequired
};
export default Loader;

View File

@@ -0,0 +1,67 @@
import {Fragment} from 'react';
import PropTypes from 'prop-types';
function Modal(props) {
if (!props.showModal) return null;
return (
<Fragment>
<div
className={
'justify-center items-center flex overflow-x-hidden overflow-y-auto fixed \
inset-0 z-50 outline-none focus:outline-none'
}
>
<div className='relative w-auto my-6 mx-auto w-4/5 max-w-6xl'>
<div
className={
'border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white \
outline-none focus:outline-none'
}
>
<button
className={
'absolute top-0 left-0 z-10 p-1 mr-auto bg-transparent border-0 text-black opacity-50 float-left \
text-3xl leading-none font-semibold outline-none focus:outline-none'
}
type='button'
onClick={props.onClose}
>
<span
className={
'bg-transparent text-black opacity-50 h-6 w-6 text-2xl block outline-none focus:outline-none'
}
>
×
</span>
</button>
{props.header && (
<div className='flex items-start justify-between p-5 border-b border-solid border-blueGray-200 rounded-t'>
<p className='text-2xl font-medium'>{props.header}</p>
</div>
)}
<div className='relative p-6 flex-auto'>
<p className='my-4 text-blueGray-500 text-lg leading-relaxed'>
{props.body}
</p>
</div>
<div className='flex items-center justify-end p-4 border-t border-solid border-blueGray-200 rounded-b'>
{props.footer}
</div>
</div>
</div>
</div>
<div className='opacity-75 fixed inset-0 z-40 bg-black'/>
</Fragment>
);
}
Modal.propTypes = {
showModal: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
header: PropTypes.node,
body: PropTypes.node.isRequired,
footer: PropTypes.node.isRequired
};
export default Modal;

View File

@@ -0,0 +1,51 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Segment(props) {
return (
<div
id={props.id}
className={classNames(
'm-4 p-4 overflow-y-auto',
'border-2 rounded shadow-sm',
getBorderClass(props.borderWeight, props.color, props.colorWeight),
getTextAlignClass(props.textAlign),
props.className
)}
>
{props.children}
</div>
);
}
function getTextAlignClass(textAlign) {
return `text-${textAlign}`;
}
function getBorderClass(borderWeight, color, colorWeight) {
const borderClass = borderWeight === 1 ? 'border' : `border-${borderWeight}`;
const borderColorClass = color ?
colorWeight ? `border-${color}-${colorWeight}` : `border-${color}-${colorWeight}` :
'border-gray-300';
return `${borderClass} ${borderColorClass}`;
}
Segment.defaultProps = {
textAlign: 'start',
borderWeight: 1
};
Segment.propTypes = {
id: PropTypes.string,
color: PropTypes.string,
colorWeight: PropTypes.number,
borderWeight: PropTypes.number.isRequired,
textAlign: PropTypes.oneOf(['start', 'center', 'end']).isRequired,
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
className: PropTypes.string
};
export default Segment;

View File

@@ -0,0 +1,44 @@
svg.spinner{
animation: 2s linear infinite spinner-animation;
max-width: 100px;
}
@keyframes spinner-animation {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg)
}
}
svg.spinner circle {
animation: 1.4s ease-in-out infinite both spinner-circle-animation;
display: block;
fill: transparent;
stroke: #2f3d4c;
stroke-linecap: round;
stroke-dasharray: 283;
stroke-dashoffset: 280;
stroke-width: 8px;
transform-origin: 50% 50%;
}
@keyframes spinner-circle-animation {
0%,
25% {
stroke-dashoffset: 280;
transform: rotate(0);
}
50%,
75% {
stroke-dashoffset: 75;
transform: rotate(45deg);
}
100% {
stroke-dashoffset: 280;
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45"/>
</svg>

After

Width:  |  Height:  |  Size: 104 B

View File

@@ -0,0 +1,10 @@
import PropTypes from 'prop-types';
import {useUser} from '../../hooks/user';
export function UseUser(props) {
return props.children(useUser());
}
UseUser.propTypes = {
children: PropTypes.func.isRequired
};

View File

@@ -0,0 +1,19 @@
import StudentsCountChart from './students-count-chart';
import {useStudentsCountData} from '../../hooks/student';
import Loader from '../shared/loader';
import Error from '../shared/error';
function StudentsCountContainer() {
const {isLoading, isError, data, error} = useStudentsCountData();
if (isLoading) return <Loader />;
if (isError) return <Error error={error} />;
return (
<div className='relative mx-auto h-[45vh] w-[80vw] sm:h-[60vh] md:h-[80vh]'>
<StudentsCountChart studentsData={data} />
</div>
);
}
export default StudentsCountContainer;

View File

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

View File

@@ -0,0 +1,93 @@
import React from 'react';
import {Chart as ChartJS, defaults, registerables} from 'chart.js';
import {Line} from 'react-chartjs-2';
import PropTypes from 'prop-types';
import jalaliFormat from 'date-fns-jalali/format/index.js';
import 'chartjs-adapter-date-fns';
function StudentsCountChart(props) {
return (
<Line
options={options}
data={{
datasets: [
{
label: 'تعداد کاربرها',
data: props.studentsData.map(({date: x, count: y}) => ({x, y})),
fill: false,
borderColor: 'blue'
}
]
}}
type={'line'}
/>
);
}
ChartJS.register(...registerables);
defaults.font.family = 'IRANSANS';
defaults.font.size = 16;
export const options = {
responsive: true,
maintainAspectRatio: false,
layout: {
margin: {
left: 5
},
padding: {
left: 0
}
},
elements: {
point: {
radius: 1,
hoverRadius: 3
}
},
plugins: {
title: {
text: 'تعداد داوطلبان پنل فلش کارت',
display: true
},
tooltip: {
callbacks: {
title: function (TooltipItems) {
return TooltipItems.map(tootltipItem =>
jalaliFormat(new Date(tootltipItem.raw.x), 'yyyy MMMM dd')
);
}
}
},
legend: {
display: true,
position: 'bottom'
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day',
stepSize: 30,
tooltipFormat: 'dd-MM-yyyy',
displayFormats: {
quarter: 'MMM YYYY'
}
},
title: {
display: true,
text: 'روز'
},
ticks: {
callback: (value, index, ticks) => jalaliFormat(new Date(ticks[index].value), 'd MMMM')
}
}
}
};
StudentsCountChart.propTypes = {
studentsData: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default StudentsCountChart;

View File

@@ -0,0 +1,15 @@
import {ERROR, INIT, LOADING, OK} from '../../redux/constants';
export function fetchData(fetchFunction) {
fetchFunction();
}
export function mergeStatuses(statuses) {
let isOK = true;
for (let i = 0; i < statuses.length; i++) {
if (statuses[i] === INIT) return INIT;
if (statuses[i] === ERROR) return ERROR;
isOK = isOK && (statuses[i] === OK);
}
return isOK ? OK : LOADING;
}

View File

@@ -0,0 +1 @@
export const BLANK_TEXT = '---';

14
src/hooks/auth.js Normal file
View File

@@ -0,0 +1,14 @@
import {useMutation, useQueryClient} from 'react-query';
import {ACCESS_TOKEN_KEY, fetchToken, REFRESH_TOKEN_KEY} from '../api/auth';
export function useLoginMutation() {
const queryClient = useQueryClient();
function onSuccess({access, refresh}) {
localStorage.setItem(ACCESS_TOKEN_KEY, access);
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
queryClient.invalidateQueries('user');
}
return useMutation(fetchToken, {onSuccess});
}

0
src/hooks/index.js Normal file
View File

6
src/hooks/order.js Normal file
View File

@@ -0,0 +1,6 @@
import {useQuery} from 'react-query';
import {fetchOrderData} from '../api/order';
export function useOrdersData() {
return useQuery('orders-data', fetchOrderData);
}

View File

@@ -0,0 +1,7 @@
import {useLocation} from 'react-router-dom';
function useQueryParams() {
return Object.fromEntries(new URLSearchParams(useLocation().search));
}
export default useQueryParams;

6
src/hooks/student.js Normal file
View File

@@ -0,0 +1,6 @@
import {useQuery} from 'react-query';
import {fetchStudentsCountData} from '../api/student';
export function useStudentsCountData() {
return useQuery('users-data', fetchStudentsCountData);
}

7
src/hooks/user.js Normal file
View File

@@ -0,0 +1,7 @@
import {useQuery} from 'react-query';
import {fetchUser} from '../api/user';
export function useUser() {
const data = useQuery('user', fetchUser, {staleTime: 5 * 60 * 1000, retry: false});
return {...data, isAuthenticated: data.status === 'success'};
}

41
src/index.css Normal file
View File

@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
b, strong {
font-weight: bold;
}
#question ol, #question ul,
#answer ol, #answer ul {
@apply list-disc list-inside;
@apply ps-4 m-2;
}
#question table, #question img,
#answer table, #answer img {
@apply mx-auto;
}
#question table tr, #question table td,
#answer table tr, #answer table td {
@apply border-collapse border border-black;
@apply text-center p-1;
}
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

33
src/index.js Normal file
View File

@@ -0,0 +1,33 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {QueryClient, QueryClientProvider} from 'react-query';
import {ReactQueryDevtools} from 'react-query/devtools';
import {ToastProvider} from 'react-toast-notifications';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1
}
}
});
ReactDOM.render(
<QueryClientProvider client={queryClient}>
<ToastProvider autoDismiss>
<React.StrictMode>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</React.StrictMode>
</ToastProvider>
</QueryClientProvider>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

13
src/reportWebVitals.js Normal file
View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

9
src/setupTests.js Normal file
View File

@@ -0,0 +1,9 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({adapter: new Adapter()});

19
tailwind.config.js Normal file
View File

@@ -0,0 +1,19 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
content: [
'./src/components/**/*.{js,jsx,ts,tsx}',
'./public/index.html',
'./tailwind.safetext.txt'
],
theme: {
extend: {
screens: {
'xs': '475px'
},
},
},
plugins: [
require('tailwindcss-rtl')
]
};

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