This commit is contained in:
2026-08-08 19:05:08 +03:30
commit 30bd9fc01c
102 changed files with 14450 additions and 0 deletions

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