start
This commit is contained in:
0
src/App.css
Normal file
0
src/App.css
Normal file
26
src/App.js
Normal file
26
src/App.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import {BrowserRouter as Router, Route, Switch} from 'react-router-dom';
|
||||
import moment from 'moment-jalaali';
|
||||
import HomePage from './components/home';
|
||||
import DefaultPage from './components/default';
|
||||
import LoginPage from './components/login';
|
||||
import OrdersPage from './components/orders';
|
||||
import StudentsCountPage from './components/students-count';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router basename={process.env.PUBLIC_URL}>
|
||||
<Switch>
|
||||
<Route exact path='/' component={HomePage} />
|
||||
<Route exact path='/login' component={LoginPage} />
|
||||
<Route exact path='/orders' component={OrdersPage} />
|
||||
<Route exact path='/students-count' component={StudentsCountPage} />
|
||||
<Route path='*' component={DefaultPage} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
moment.loadPersian({usePersianDigits: false, dialect: 'persian-modern'});
|
||||
|
||||
export default App;
|
||||
0
src/App.min.css
vendored
Normal file
0
src/App.min.css
vendored
Normal file
42
src/api/auth.js
Normal file
42
src/api/auth.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import {request} from './index';
|
||||
|
||||
// local storage keys
|
||||
export const ACCESS_TOKEN_KEY = 'access_token';
|
||||
export const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
export async function getToken() {
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (accessToken) return accessToken;
|
||||
return await getFreshToken();
|
||||
}
|
||||
|
||||
export async function getFreshToken() {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) throw Error('Refresh token not found.');
|
||||
const data = await fetchFreshToken(refreshToken);
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, data.access);
|
||||
return data.access;
|
||||
}
|
||||
|
||||
export async function fetchToken({mobile, password}) {
|
||||
return await request({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
data: {mobile, password}
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchFreshToken(token) {
|
||||
return await request({
|
||||
method: 'POST',
|
||||
url: '/auth/refresh',
|
||||
data: {refresh: token}
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutUser(queryClient, history = null) {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
queryClient.invalidateQueries('user');
|
||||
if (history) history.push('/login');
|
||||
}
|
||||
28
src/api/index.js
Normal file
28
src/api/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios';
|
||||
import {getFreshToken, getToken} from './auth';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_BASE_URL,
|
||||
headers: {}
|
||||
});
|
||||
|
||||
export async function request(options) {
|
||||
const {auth, ...requestOptions} = options;
|
||||
if (auth) {
|
||||
const accessToken = await getToken();
|
||||
requestOptions.headers = {...requestOptions.headers, Authorization: `Bearer ${accessToken}`};
|
||||
}
|
||||
|
||||
try {
|
||||
return (await apiClient(requestOptions)).data;
|
||||
} catch (error) {
|
||||
if (!auth || error.response?.status !== 401) throw error.response;
|
||||
const accessToken = await getFreshToken();
|
||||
requestOptions.headers.Authorization = `Bearer ${accessToken}`;
|
||||
try {
|
||||
return (await apiClient(requestOptions)).data;
|
||||
} catch (error) {
|
||||
throw error.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/api/order.js
Normal file
5
src/api/order.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import {request} from './index';
|
||||
|
||||
export async function fetchOrderData() {
|
||||
return await request({url: '/decks/ordered-decks/data', auth: true});
|
||||
}
|
||||
5
src/api/student.js
Normal file
5
src/api/student.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import {request} from './index';
|
||||
|
||||
export async function fetchStudentsCountData() {
|
||||
return await request({url: '/students/students-count-data', auth: true});
|
||||
}
|
||||
5
src/api/user.js
Normal file
5
src/api/user.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import {request} from './index';
|
||||
|
||||
export async function fetchUser() {
|
||||
return await request({url: '/users/user', auth: true});
|
||||
}
|
||||
9
src/components/default/index.js
Normal file
9
src/components/default/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
|
||||
function DefaultPage() {
|
||||
return (
|
||||
<MainLayout loginRequired={false}/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultPage;
|
||||
12
src/components/home/home.js
Normal file
12
src/components/home/home.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<Segment>
|
||||
<p className='text-large'>پنل هوش تجاری فلش کارت های گروه مد مشاور</p>
|
||||
<p>به پنل هوش تجاری فلش کارت های گروه مدمشاور خوش آمدید.</p>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
16
src/components/home/index.js
Normal file
16
src/components/home/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import Home from './home';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<MainLayout
|
||||
loginRequired={false}
|
||||
consultPackageRequired={false}
|
||||
packageRequired={false}
|
||||
>
|
||||
<Home/>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
53
src/components/layouts/main/auth-header.js
Normal file
53
src/components/layouts/main/auth-header.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import {useHistory} from 'react-router-dom';
|
||||
import {useQueryClient} from 'react-query';
|
||||
import PropTypes from 'prop-types';
|
||||
import NavLink from '../../shared/buttons/nav-link';
|
||||
import ButtonGroup from '../../shared/buttons/button-group';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import {useUser} from '../../../hooks/user';
|
||||
import {logoutUser} from '../../../api/auth';
|
||||
|
||||
function AuthHeader(props) {
|
||||
const history = useHistory();
|
||||
const {status, data: user} = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const nextURL = `${history.location.pathname}${history.location.search.replace('&', ';')}`;
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<ButtonGroup className={props.className}>
|
||||
<NavLink
|
||||
to={`/login?next=${nextURL}`}
|
||||
color='blue'
|
||||
content='ورود'
|
||||
className={props.className}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup className={props.className}>
|
||||
<NavLink
|
||||
to='/profile'
|
||||
content={user?.name || user?.mobile}
|
||||
loading={status === 'loading'}
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
<Button
|
||||
content='خروج'
|
||||
color='red'
|
||||
onClick={() => logoutUser(queryClient, history)}
|
||||
loading={status === 'loading'}
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
AuthHeader.propTypes = {
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default AuthHeader;
|
||||
7
src/components/layouts/main/footer.js
Normal file
7
src/components/layouts/main/footer.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function Footer() {
|
||||
return (
|
||||
<footer/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
78
src/components/layouts/main/header.js
Normal file
78
src/components/layouts/main/header.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import {useState} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {MenuIcon} from '@heroicons/react/outline';
|
||||
import {XIcon} from '@heroicons/react/solid';
|
||||
import AuthHeader from './auth-header';
|
||||
import NavLink from '../../shared/buttons/nav-link';
|
||||
|
||||
function Header() {
|
||||
const [isHidden, setIsHidden] = useState(true);
|
||||
const iconSize = 34;
|
||||
|
||||
return (
|
||||
<header className='sticky top-0 z-50'>
|
||||
<nav className='flex flex-wrap items-center justify-between bg-gray-800 p-2'>
|
||||
<NavLink to='/' color='transparent' className='hidden md:block'>
|
||||
<p className='text-lg font-bold'>Med Moshaver</p>
|
||||
</NavLink>
|
||||
|
||||
<AuthHeader className='mx-2 md:order-last' />
|
||||
|
||||
<div className='flex md:hidden'>
|
||||
<div className='flex md:hidden'>
|
||||
<button id='hamburger' onClick={() => setIsHidden(isHidden => !isHidden)}>
|
||||
<MenuIcon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className={classNames(
|
||||
'toggle block',
|
||||
{hidden: !isHidden},
|
||||
'rounded-md border-2 border-blue-500 stroke-current p-1 text-blue-500'
|
||||
)}
|
||||
/>
|
||||
<XIcon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className={classNames(
|
||||
'toggle block',
|
||||
{hidden: isHidden},
|
||||
'rounded-md border-2 border-blue-500 stroke-current p-1 text-blue-500'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'toggle',
|
||||
{hidden: isHidden},
|
||||
'text-bold mt-5 w-full text-right md:mt-0 md:flex md:w-auto',
|
||||
'border-t-2 border-blue-900 md:border-none'
|
||||
)}
|
||||
>
|
||||
{links.map(link => (
|
||||
<div
|
||||
key={link.id}
|
||||
className={`
|
||||
w-100 block rounded-none border-t-2 border-blue-500
|
||||
px-1 py-1 md:inline-block md:border-none
|
||||
`}
|
||||
>
|
||||
<NavLink to={link.href} textSize='base' color='transparent'>
|
||||
{link.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const links = [
|
||||
{id: 1, name: 'خانه', href: '/'},
|
||||
{id: 2, name: 'فروش', href: '/orders'}
|
||||
];
|
||||
|
||||
export default Header;
|
||||
69
src/components/layouts/main/index.js
Normal file
69
src/components/layouts/main/index.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import {Component, Fragment} from 'react';
|
||||
import {withRouter, Redirect} from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import {UseUser} from '../../shared/use-user';
|
||||
import Header from './header';
|
||||
import Container from '../../shared/container';
|
||||
import Footer from './footer';
|
||||
import ScrollToTop from '../../shared/buttons/scroll-to-top';
|
||||
import Loader from '../../shared/loader';
|
||||
|
||||
class MainLayout extends Component {
|
||||
componentDidMount() {
|
||||
const {title, description} = this.props;
|
||||
if (title) document.title = title;
|
||||
if (description) document.description = description;
|
||||
}
|
||||
|
||||
renderContent = status => {
|
||||
const {loginRequired} = this.props;
|
||||
|
||||
if (loginRequired && status === 'loading') return <div><Loader/></div>;
|
||||
if (!loginRequired || status === 'success') return this.props.children;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {location, loginRequired} = this.props;
|
||||
const currentURL = `${location.pathname}${location.search.replace('&', ';')}`;
|
||||
|
||||
return (
|
||||
<UseUser>
|
||||
{({status}) => {
|
||||
if (loginRequired && status === 'error') return <Redirect to={`/login?next=${currentURL}`}/>;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex flex-col h-screen'>
|
||||
<Header/>
|
||||
<main className='flex-1 py-4'>
|
||||
<Container>
|
||||
{this.renderContent(status)}
|
||||
</Container>
|
||||
</main>
|
||||
<Footer/>
|
||||
</div>
|
||||
<ScrollToTop/>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
</UseUser>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MainLayout.propTypes = {
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
loginRequired: PropTypes.bool.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
])
|
||||
};
|
||||
|
||||
MainLayout.defaultProps = {
|
||||
loginRequired: true
|
||||
};
|
||||
|
||||
export default withRouter(MainLayout);
|
||||
64
src/components/login/form.js
Normal file
64
src/components/login/form.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Fragment} from 'react';
|
||||
import {Field, Form, Formik} from 'formik';
|
||||
import Segment from '../shared/segment';
|
||||
import {mobile, required} from '../shared/forms/validations';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import Button from '../shared/buttons/button';
|
||||
import Alert from '../shared/alert';
|
||||
import ButtonGroup from '../shared/buttons/button-group';
|
||||
import {useLoginMutation} from '../../hooks/auth';
|
||||
|
||||
function LoginForm() {
|
||||
const {mutate} = useLoginMutation();
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Formik
|
||||
initialValues={{mobile: '', password: ''}}
|
||||
onSubmit={submit.bind(null, mutate)}
|
||||
>
|
||||
{({errors, dirty}) => (
|
||||
<Fragment>
|
||||
{(dirty && errors._error) && (
|
||||
<Alert content={errors._error} className='mt-3 mb-5'/>
|
||||
)}
|
||||
|
||||
<Form className='grid grid-cols-1 gap-5 p-3'>
|
||||
<Field
|
||||
required
|
||||
name='mobile'
|
||||
label='شماره تلفن همراه'
|
||||
type='tel'
|
||||
placeholder='09121234567'
|
||||
component={FormInput}
|
||||
validate={mobile}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='password'
|
||||
label='رمز عبور'
|
||||
type='password'
|
||||
component={FormInput}
|
||||
validate={required}
|
||||
/>
|
||||
<ButtonGroup float>
|
||||
<Button type='submit' color='green' content='ورود'/>
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
async function submit(loginUser, data, {setErrors}) {
|
||||
try {
|
||||
await loginUser(data);
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || 'شماره تلفن یا رمز عبور نادرست است.';
|
||||
setErrors({_error: errorMessage});
|
||||
}
|
||||
}
|
||||
|
||||
export default LoginForm;
|
||||
27
src/components/login/index.js
Normal file
27
src/components/login/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Redirect} from 'react-router-dom';
|
||||
import MainLayout from '../layouts/main';
|
||||
import LoginForm from './form';
|
||||
import useQueryParams from '../../hooks/query-params';
|
||||
import {useUser} from '../../hooks/user';
|
||||
|
||||
function LoginPage() {
|
||||
const {isAuthenticated} = useUser();
|
||||
const {next} = useQueryParams();
|
||||
|
||||
if (isAuthenticated) {
|
||||
const nextURL = next ? next.replace(';', '&') : '/orders';
|
||||
return (<Redirect to={nextURL}/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout loginRequired={false}>
|
||||
<div className='flex justify-center'>
|
||||
<div className='w-full md:w-3/4 lg:w-2/3 xl:w-1/2 2xl:w-1/3'>
|
||||
<LoginForm/>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
14
src/components/orders/container.js
Normal file
14
src/components/orders/container.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Orders from './orders';
|
||||
import {useOrdersData} from '../../hooks/order';
|
||||
import Loader from '../shared/loader';
|
||||
import Error from '../shared/error';
|
||||
|
||||
function OrdersContainer() {
|
||||
const {isLoading, isError, data, error} = useOrdersData();
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
if (isError) return <Error error={error} />;
|
||||
return <Orders orderItems={data} />;
|
||||
}
|
||||
|
||||
export default OrdersContainer;
|
||||
12
src/components/orders/index.js
Normal file
12
src/components/orders/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import OrdersContainer from './container';
|
||||
|
||||
function OrdersPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<OrdersContainer/>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrdersPage;
|
||||
197
src/components/orders/orders.js
Normal file
197
src/components/orders/orders.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment-jalaali';
|
||||
|
||||
class Orders extends Component {
|
||||
state = {
|
||||
showCount: true,
|
||||
showTotalPrice: true,
|
||||
showAuthorPrice: false,
|
||||
showUnits: true
|
||||
};
|
||||
|
||||
toggleShowCount = () => this.setState(state => ({showCount: !state.showCount}));
|
||||
toggleShowTotalPrice = () => this.setState(state => ({showTotalPrice: !state.showTotalPrice}));
|
||||
toggleShowAuthorPrice = () => this.setState(state => ({showAuthorPrice: !state.showAuthorPrice}));
|
||||
toggleShowUnits = () => this.setState(state => ({showUnits: !state.showUnits}));
|
||||
|
||||
presentPrice = price => (this.state.showUnits ? `${price.toLocaleString()} ریال` : price);
|
||||
presentCount = count => (this.state.showUnits ? `${count} عدد` : count);
|
||||
|
||||
getMonthkeys = () => {
|
||||
const keys = [];
|
||||
const today = moment();
|
||||
let month = today.jMonth() + 1;
|
||||
let year = today.jYear();
|
||||
while (year > 1400 || month > 5) {
|
||||
keys.push(`${year}-${month < 10 ? `0${month}` : month}`);
|
||||
if (month > 1) {
|
||||
month = month - 1;
|
||||
} else {
|
||||
month = 12;
|
||||
year = year - 1;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {showCount, showTotalPrice, showAuthorPrice, showUnits} = this.state;
|
||||
const monthKeys = this.getMonthkeys();
|
||||
|
||||
const dataByOrderItem = {};
|
||||
for (const orderItem of this.props.orderItems) {
|
||||
let price = 0;
|
||||
let count = 0;
|
||||
for (const monthKey in orderItem.data) {
|
||||
if (monthKeys.includes(monthKey)) {
|
||||
price += orderItem.data[monthKey].totalPrice;
|
||||
count += orderItem.data[monthKey].count;
|
||||
}
|
||||
}
|
||||
dataByOrderItem[orderItem.id] = {price, count};
|
||||
}
|
||||
|
||||
const dataByMonthKey = {};
|
||||
for (const monthKey of monthKeys) dataByMonthKey[monthKey] = {price: 0, count: 0};
|
||||
for (const orderItem of this.props.orderItems) {
|
||||
for (const monthKey in orderItem.data) {
|
||||
if (monthKeys.includes(monthKey)) {
|
||||
dataByMonthKey[monthKey].price += orderItem.data[monthKey]?.totalPrice || 0;
|
||||
dataByMonthKey[monthKey].count += orderItem.data[monthKey]?.count || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalData = {price: 0, count: 0};
|
||||
for (const orderItemId in dataByOrderItem) {
|
||||
totalData.price += dataByOrderItem[orderItemId].price;
|
||||
totalData.count += dataByOrderItem[orderItemId].count;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-4 overflow-x-auto'>
|
||||
<p className='font-medium text-lg'>جدول فروش ماهیانه:</p>
|
||||
|
||||
<div className='flex flex-wrap justify-center gap-8 my-2'>
|
||||
<div>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='show-total-price'
|
||||
checked={showTotalPrice}
|
||||
onChange={this.toggleShowTotalPrice}
|
||||
/>
|
||||
<label htmlFor='show-total-price' className='mx-2'>
|
||||
نمایش مبلغ کل
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='show-count'
|
||||
checked={showCount}
|
||||
onChange={this.toggleShowCount}
|
||||
/>
|
||||
<label htmlFor='show-count' className='mx-2'>
|
||||
نمایش تعداد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='show-author-price'
|
||||
checked={showAuthorPrice}
|
||||
onChange={this.toggleShowAuthorPrice}
|
||||
/>
|
||||
<label htmlFor='show-author-price' className='mx-2'>
|
||||
نمایش سهم مولف
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='show-units'
|
||||
checked={showUnits}
|
||||
onChange={this.toggleShowUnits}
|
||||
/>
|
||||
<label htmlFor='show-units' className='mx-2'>
|
||||
نمایش واحدها
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className='my-4 mx-auto'>
|
||||
<thead className='border-collapse border border-gray-700'>
|
||||
<tr className='text-center font-medium bg-indigo-50'>
|
||||
<td className='border border-gray-700 p-3'>#</td>
|
||||
{monthKeys.map(orderPaid => (
|
||||
<td key={orderPaid} className='border border-gray-700 p-3'>
|
||||
{orderPaid}
|
||||
</td>
|
||||
))}
|
||||
<td className='border border-gray-700 bg-indigo-300 p-3'>مجموع</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.props.orderItems.map(orderItem => (
|
||||
<tr key={orderItem.id} className='text-center'>
|
||||
<td className='border border-gray-700 bg-indigo-50 font-medium p-3'>
|
||||
{orderItem.name}
|
||||
</td>
|
||||
{monthKeys.map(monthKey => {
|
||||
const orderPaidData = orderItem.data[monthKey];
|
||||
return (
|
||||
<td key={monthKey} className='border border-gray-700 p-3'>
|
||||
{showTotalPrice && <p>{this.presentPrice(orderPaidData?.totalPrice || 0)}</p>}
|
||||
{showCount && <p>{this.presentCount(orderPaidData?.count || 0)}</p>}
|
||||
{showAuthorPrice && (
|
||||
<p>{this.presentPrice((orderPaidData?.totalPrice || 0) / 5)}</p>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className='border border-gray-700 bg-indigo-50 p-3'>
|
||||
{showTotalPrice && (
|
||||
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price)}</p>
|
||||
)}
|
||||
{showCount && <p>{this.presentCount(dataByOrderItem[orderItem.id].count)}</p>}
|
||||
{showAuthorPrice && (
|
||||
<p>{this.presentPrice(dataByOrderItem[orderItem.id].price / 5)}</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className='text-center bg-indigo-100'>
|
||||
<td className='border border-green-700 bg-indigo-300 font-medium p-3'>مجموع</td>
|
||||
{monthKeys.map(monthKey => {
|
||||
return (
|
||||
<td key={monthKey} className='border border-gray-700 p-3'>
|
||||
{showTotalPrice && <p>{this.presentPrice(dataByMonthKey[monthKey].price)}</p>}
|
||||
{showCount && <p>{this.presentCount(dataByMonthKey[monthKey].count)}</p>}
|
||||
{showAuthorPrice && (
|
||||
<p>{this.presentPrice(dataByMonthKey[monthKey].price / 5)}</p>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className='border border-gray-700 p-3 bg-indigo-300'>
|
||||
{showTotalPrice && <p>{this.presentPrice(totalData.price)}</p>}
|
||||
{showCount && <p>{this.presentPrice(totalData.count)}</p>}
|
||||
{showAuthorPrice && <p>{this.presentPrice(totalData.price / 5)}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Orders.propTypes = {
|
||||
orderItems: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export default Orders;
|
||||
24
src/components/shared/alert.js
Normal file
24
src/components/shared/alert.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function Alert(props) {
|
||||
return (
|
||||
<div
|
||||
role='alert'
|
||||
className={classNames(
|
||||
'text-center text-lg font-medium',
|
||||
'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
content: PropTypes.node.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
50
src/components/shared/buttons/back.js
Normal file
50
src/components/shared/buttons/back.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import {useHistory} from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from './button';
|
||||
|
||||
function BackLink(props) {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={props.color}
|
||||
outline={props.outline}
|
||||
flat={props.flat}
|
||||
size={props.size}
|
||||
loading={props.loading}
|
||||
disabled={props.disabled}
|
||||
className={props.className}
|
||||
onClick={history.goBack}
|
||||
>
|
||||
{props.content || props.children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
BackLink.defaultProps = {
|
||||
outline: false,
|
||||
flat: false,
|
||||
size: 'base',
|
||||
textSize: 'sm',
|
||||
color: 'blue',
|
||||
loading: false,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
BackLink.propTypes = {
|
||||
content: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
outline: PropTypes.bool.isRequired,
|
||||
flat: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default BackLink;
|
||||
25
src/components/shared/buttons/button-group.css
Normal file
25
src/components/shared/buttons/button-group.css
Normal file
@@ -0,0 +1,25 @@
|
||||
div.btn-group.btn-group-stretched a,
|
||||
div.btn-group.btn-group-stretched button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.btn-group a,
|
||||
div.btn-group button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.btn-group button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
div.btn-group > button:first-child,
|
||||
div.btn-group > a:first-child > button:first-child {
|
||||
border-top-right-radius: .5rem;
|
||||
border-bottom-right-radius: .5rem;
|
||||
}
|
||||
|
||||
div.btn-group > button:last-child,
|
||||
div.btn-group > a:last-child > button:last-child {
|
||||
border-top-left-radius: .5rem;
|
||||
border-bottom-left-radius: .5rem;
|
||||
}
|
||||
29
src/components/shared/buttons/button-group.js
Normal file
29
src/components/shared/buttons/button-group.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import './button-group.css';
|
||||
|
||||
function ButtonGroup(props) {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex btn-group',
|
||||
{'btn-group-stretched': props.float},
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ButtonGroup.defaultProps = {float: false};
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
float: PropTypes.bool.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
1
src/components/shared/buttons/button-group.min.css
vendored
Normal file
1
src/components/shared/buttons/button-group.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
div.btn-group{margin:.5rem}div.btn-group>button{margin:0;border-radius:0}div.btn-group>button:first-child{border-top-right-radius:.5rem;border-bottom-right-radius:.5rem}div.btn-group>button:last-child{border-top-left-radius:.5rem;border-bottom-left-radius:.5rem}
|
||||
120
src/components/shared/buttons/button.js
Normal file
120
src/components/shared/buttons/button.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import FilledButton from './filled-button';
|
||||
import FlatButton from './flat-button';
|
||||
import OutlineButton from './outline-button';
|
||||
|
||||
class Button extends Component {
|
||||
getSizeClass = () => {
|
||||
const sizePaddingMap = {
|
||||
default: {
|
||||
zero: `p-0`,
|
||||
xs: 'py-0.5 px-1',
|
||||
sm: 'py-1 px-2',
|
||||
base: 'py-1.5 px-3',
|
||||
lg: 'py-2 px-4',
|
||||
xl: 'py-2.5 px-5'
|
||||
},
|
||||
circular: {
|
||||
zero: `p-0`,
|
||||
xs: 'p-0.5',
|
||||
sm: 'p-1',
|
||||
base: 'p-1.5',
|
||||
lg: 'p-2',
|
||||
xl: 'p-2.5'
|
||||
}
|
||||
};
|
||||
const {size, textSize, circular} = this.props;
|
||||
return `text-${textSize} ${sizePaddingMap[circular ? 'circular' : 'default'][size]}`;
|
||||
};
|
||||
|
||||
getContainer = () => {
|
||||
if (this.props.flat) return FlatButton;
|
||||
if (this.props.outline) return OutlineButton;
|
||||
return FilledButton;
|
||||
};
|
||||
|
||||
render() {
|
||||
const Container = this.getContainer();
|
||||
|
||||
return (
|
||||
<Container
|
||||
{...this.props}
|
||||
className={classNames(
|
||||
this.getSizeClass(),
|
||||
this.props.inline ? 'inline-flex' : 'flex',
|
||||
`justify-${this.props.justifyContent} items-center`,
|
||||
this.props.className
|
||||
)}
|
||||
>
|
||||
{(this.props.content !== undefined) ? this.props.content : this.props.children}
|
||||
{this.props.loading && (
|
||||
<svg
|
||||
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<circle
|
||||
className='opacity-25'
|
||||
cx='12'
|
||||
cy='12'
|
||||
r='10'
|
||||
stroke='currentColor'
|
||||
strokeWidth='4'
|
||||
/>
|
||||
<path
|
||||
className='opacity-75'
|
||||
fill='currentColor'
|
||||
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.defaultProps = {
|
||||
type: 'button',
|
||||
inline: false,
|
||||
outline: false,
|
||||
flat: false,
|
||||
size: 'base',
|
||||
textSize: 'sm',
|
||||
justifyContent: 'center',
|
||||
color: 'blue',
|
||||
colorWeight: 500,
|
||||
borderWeight: 1,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
circular: false
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
content: PropTypes.string,
|
||||
color: PropTypes.string.isRequired,
|
||||
colorWeight: PropTypes.number.isRequired,
|
||||
borderWeight: PropTypes.number.isRequired,
|
||||
textColor: PropTypes.string,
|
||||
textColorWeight: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
inline: PropTypes.bool.isRequired,
|
||||
outline: PropTypes.bool.isRequired,
|
||||
flat: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
justifyContent: PropTypes.oneOf(['start', 'center', 'end', 'between', 'around', 'evenly']).isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
circular: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Button;
|
||||
64
src/components/shared/buttons/filled-button.js
Normal file
64
src/components/shared/buttons/filled-button.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {withoutWeightColor} from './utils';
|
||||
|
||||
class FilledButton extends Component {
|
||||
getColorClass = () => {
|
||||
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
|
||||
|
||||
return classNames(
|
||||
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||
{'text-white': !textColor},
|
||||
{'opacity-40 cursor-not-allowed': disabled},
|
||||
withoutWeightColor.includes(color) ? `bg-${color}` : `bg-${color}-${colorWeight}`,
|
||||
{[`hover:bg-${color}-${colorWeight + 100}`]: !withoutWeightColor.includes(color) && !disabled}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button
|
||||
type={this.props.type}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.props.onClick}
|
||||
className={classNames(
|
||||
'focus:outline-none rounded-md',
|
||||
this.getColorClass(),
|
||||
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||
this.props.className
|
||||
)}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilledButton.defaultProps = {
|
||||
type: 'button',
|
||||
color: 'blue',
|
||||
colorWeight: 500,
|
||||
borderWeight: 1,
|
||||
loading: false,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
FilledButton.propTypes = {
|
||||
color: PropTypes.string.isRequired,
|
||||
colorWeight: PropTypes.number.isRequired,
|
||||
borderWeight: PropTypes.number.isRequired,
|
||||
textColor: PropTypes.string,
|
||||
textColorWeight: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
loading: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default FilledButton;
|
||||
61
src/components/shared/buttons/flat-button.js
Normal file
61
src/components/shared/buttons/flat-button.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {withoutWeightColor} from './utils';
|
||||
|
||||
class FlatButton extends Component {
|
||||
getColorClass = () => {
|
||||
const {color, colorWeight, textColor, textColorWeight, disabled} = this.props;
|
||||
|
||||
return classNames(
|
||||
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||
{[`text-${color}-${colorWeight}`]: !textColor},
|
||||
{'opacity-40 cursor-not-allowed': disabled},
|
||||
{[`hover:bg-${color}-100`]: !withoutWeightColor.includes(color) && !disabled}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button
|
||||
type={this.props.type}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.props.onClick}
|
||||
className={classNames(
|
||||
'focus:outline-none rounded-md',
|
||||
this.getColorClass(),
|
||||
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||
this.props.className
|
||||
)}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FlatButton.defaultProps = {
|
||||
type: 'button',
|
||||
color: 'blue',
|
||||
colorWeight: 500,
|
||||
borderWeight: 1,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
FlatButton.propTypes = {
|
||||
color: PropTypes.string.isRequired,
|
||||
colorWeight: PropTypes.number.isRequired,
|
||||
borderWeight: PropTypes.number.isRequired,
|
||||
textColor: PropTypes.string,
|
||||
textColorWeight: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default FlatButton;
|
||||
56
src/components/shared/buttons/nav-link.js
Normal file
56
src/components/shared/buttons/nav-link.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from './button';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function NavLink(props) {
|
||||
const content = (
|
||||
<Button
|
||||
color={props.color}
|
||||
outline={props.outline}
|
||||
flat={props.flat}
|
||||
size={props.size}
|
||||
textSize={props.textSize}
|
||||
loading={props.loading}
|
||||
disabled={props.disabled}
|
||||
className={classNames(props.className, 'min-w-max')}
|
||||
>
|
||||
{props.content || props.children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (props.disabled) return content;
|
||||
return (
|
||||
<Link to={props.to} replace={props.replace} className={props.className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
NavLink.defaultProps = {
|
||||
outline: false,
|
||||
flat: false,
|
||||
size: 'base',
|
||||
textSize: 'sm',
|
||||
color: 'blue',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
replace: false
|
||||
};
|
||||
|
||||
NavLink.propTypes = {
|
||||
content: PropTypes.string,
|
||||
to: PropTypes.string.isRequired,
|
||||
color: PropTypes.string,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
outline: PropTypes.bool.isRequired,
|
||||
flat: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
replace: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
64
src/components/shared/buttons/outline-button.js
Normal file
64
src/components/shared/buttons/outline-button.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {withoutWeightColor} from './utils';
|
||||
|
||||
class OutlineButton extends Component {
|
||||
getColorClass = () => {
|
||||
const {color, colorWeight, borderWeight, textColor, textColorWeight, disabled} = this.props;
|
||||
|
||||
return classNames(
|
||||
{[textColorWeight ? `text-${textColor}-${textColorWeight}` : `text-${textColor}`]: textColor},
|
||||
{[`text-${color}-${colorWeight}`]: !textColor},
|
||||
{'opacity-40 cursor-not-allowed': disabled},
|
||||
(borderWeight === 1) ? 'border' : `border-${borderWeight}`,
|
||||
withoutWeightColor.includes(color) ? `border-${color}` : `border-${color}-${colorWeight}`,
|
||||
{[`hover:bg-${color}-50`]: !withoutWeightColor.includes(color) && !disabled}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button
|
||||
type={this.props.type}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.props.onClick}
|
||||
className={classNames(
|
||||
'focus:outline-none rounded-md',
|
||||
this.getColorClass(),
|
||||
{'hover:shadow-lg hover:font-semibold': !this.props.disabled},
|
||||
this.props.className
|
||||
)}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OutlineButton.defaultProps = {
|
||||
type: 'button',
|
||||
color: 'blue',
|
||||
colorWeight: 500,
|
||||
borderWeight: 1,
|
||||
loading: false,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
OutlineButton.propTypes = {
|
||||
color: PropTypes.string.isRequired,
|
||||
colorWeight: PropTypes.number.isRequired,
|
||||
borderWeight: PropTypes.number.isRequired,
|
||||
textColor: PropTypes.string,
|
||||
textColorWeight: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default OutlineButton;
|
||||
54
src/components/shared/buttons/scroll-to-top.js
Normal file
54
src/components/shared/buttons/scroll-to-top.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {ChevronUpIcon} from '@heroicons/react/solid';
|
||||
import Button from './button';
|
||||
|
||||
class ScrollToTop extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {isVisible: false};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
|
||||
handleScrollEvent = () => {
|
||||
this.setState({isVisible: window.pageYOffset > 300});
|
||||
};
|
||||
|
||||
scrollToTop = () => {
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.isVisible) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
circular
|
||||
color='gray'
|
||||
colorWeight={400}
|
||||
size='lg'
|
||||
onClick={this.scrollToTop}
|
||||
className={`fixed bottom-${this.props.withFooter ? 16 : 5} left-5 rounded-full text-white`}
|
||||
>
|
||||
<ChevronUpIcon width={24} height={24}/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScrollToTop.defaultProps = {
|
||||
withFooter: false
|
||||
};
|
||||
|
||||
ScrollToTop.propTypes = {
|
||||
withFooter: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
1
src/components/shared/buttons/utils.js
Normal file
1
src/components/shared/buttons/utils.js
Normal file
@@ -0,0 +1 @@
|
||||
export const withoutWeightColor = ['inherit', 'current', 'transparent', 'black', 'white'];
|
||||
20
src/components/shared/container.js
Normal file
20
src/components/shared/container.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function Container(props) {
|
||||
return (
|
||||
<div className={classNames('container m-auto', props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Container.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Container;
|
||||
3
src/components/shared/divider.css
Normal file
3
src/components/shared/divider.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.divider {
|
||||
line-height: 0;
|
||||
}
|
||||
18
src/components/shared/divider.js
Normal file
18
src/components/shared/divider.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import './divider.css';
|
||||
|
||||
function Divider(props) {
|
||||
return (
|
||||
<div className={classNames('divider', 'w-100 mx-0 my-6 border-b text-center', props.className)}>
|
||||
{props.content && <span className='bg-white px-0'>{props.content}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Divider.propTypes = {
|
||||
content: PropTypes.string,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Divider;
|
||||
25
src/components/shared/error.js
Normal file
25
src/components/shared/error.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function Error(props) {
|
||||
const {error} = props;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<p>خطایی رخ داده است.</p>
|
||||
{error.status && (
|
||||
<p>
|
||||
<span>کد خطا: </span>
|
||||
<span>{error.status}</span>
|
||||
</p>
|
||||
)}
|
||||
{error.data && (
|
||||
<p>{JSON.stringify(error.data)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Error.propTypes = {
|
||||
error: PropTypes.object
|
||||
};
|
||||
|
||||
export default Error;
|
||||
70
src/components/shared/fetches/fetcher.js
Normal file
70
src/components/shared/fetches/fetcher.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import {useEffect} from 'react';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Loader from '../loader';
|
||||
import Error from '../error';
|
||||
import {fetchData} from '../../utils/fetch';
|
||||
import {ERROR, INIT, LOADING} from '../../../redux/constants';
|
||||
|
||||
function Fetcher(props) {
|
||||
useEffect(
|
||||
() => fetchData(props.action),
|
||||
[props.action]
|
||||
);
|
||||
// Loading
|
||||
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
|
||||
// Error
|
||||
if (props.status === ERROR) {
|
||||
const result = props.renderError(props.error);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
// Empty Data
|
||||
if (
|
||||
(Array.isArray(props.data) && props.data.length === 0) ||
|
||||
(Object.entries(props.data).length === 0 && props.data.constructor === Object)
|
||||
) {
|
||||
const result = props.renderEmpty();
|
||||
if (result !== null) return result;
|
||||
}
|
||||
// Good Condition
|
||||
return props.children(props.data);
|
||||
}
|
||||
|
||||
Fetcher.propTypes = {
|
||||
action: PropTypes.func.isRequired,
|
||||
stateSelector: PropTypes.func.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
data: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.arrayOf(PropTypes.object)
|
||||
]).isRequired,
|
||||
renderEmpty: PropTypes.func.isRequired,
|
||||
renderError: PropTypes.func.isRequired,
|
||||
error: PropTypes.oneOfType(PropTypes.string, PropTypes.object),
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.func
|
||||
])
|
||||
};
|
||||
|
||||
Fetcher.defaultProps = {
|
||||
renderEmpty: () => null,
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderError: () => <Error/>
|
||||
};
|
||||
|
||||
function mapStateToProps(state, {stateSelector}) {
|
||||
return {
|
||||
status: stateSelector(state).status,
|
||||
error: stateSelector(state).error,
|
||||
data: stateSelector(state).data
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch, {action}) {
|
||||
return bindActionCreators({action}, dispatch);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Fetcher);
|
||||
83
src/components/shared/fetches/multi-fetcher.js
Normal file
83
src/components/shared/fetches/multi-fetcher.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import {useEffect} from 'react';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Loader from '../loader';
|
||||
import {fetchData, mergeStatuses} from '../../utils/fetch';
|
||||
import {ERROR, INIT, LOADING} from '../../../redux/constants';
|
||||
import Error from '../error';
|
||||
|
||||
function MultiFetcher(props) {
|
||||
useEffect(
|
||||
() => fetchData(props.action),
|
||||
[props.action]
|
||||
);
|
||||
// Loading
|
||||
if ([INIT, LOADING].includes(props.status)) return <Loader/>;
|
||||
// Error
|
||||
if (props.status === ERROR) return props.renderError(props.errors);
|
||||
// Empty Data
|
||||
const emptyDataKeys = [];
|
||||
for (const key in props.data) {
|
||||
const data = props.data[key];
|
||||
if (
|
||||
(Array.isArray(data) && data.length === 0) ||
|
||||
(Object.entries(data).length === 0 && data.constructor === Object)
|
||||
) emptyDataKeys.push(key);
|
||||
}
|
||||
if (emptyDataKeys.length !== 0) {
|
||||
const result = props.renderEmpty(emptyDataKeys);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
// Good Condition
|
||||
return props.children(props.data);
|
||||
}
|
||||
|
||||
MultiFetcher.propTypes = {
|
||||
actions: PropTypes.arrayOf(PropTypes.func).isRequired,
|
||||
action: PropTypes.func.isRequired,
|
||||
stateSelectors: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
renderEmpty: PropTypes.func.isRequired,
|
||||
renderError: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.func
|
||||
])
|
||||
};
|
||||
|
||||
MultiFetcher.defaultProps = {
|
||||
renderEmpty: () => null,
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderError: () => <Error/>
|
||||
};
|
||||
|
||||
function mapStateToProps(state, {stateSelectors}) {
|
||||
const statuses = [];
|
||||
const data = {};
|
||||
const errors = {};
|
||||
for (const key in stateSelectors) {
|
||||
if (!(key in stateSelectors)) continue;
|
||||
const selectedState = stateSelectors[key](state);
|
||||
statuses.push(selectedState.status);
|
||||
data[key] = selectedState.data;
|
||||
if (selectedState.status === ERROR) errors[key] = selectedState.error;
|
||||
}
|
||||
return {status: mergeStatuses(statuses), data, errors};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch, {actions}) {
|
||||
function action() {
|
||||
return function (dispatch) {
|
||||
for (let i = 0; i < actions.length; i++)
|
||||
dispatch(actions[i]());
|
||||
};
|
||||
}
|
||||
|
||||
return bindActionCreators({action}, dispatch);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MultiFetcher);
|
||||
47
src/components/shared/forms/input.js
Normal file
47
src/components/shared/forms/input.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import classNames from 'classnames';
|
||||
|
||||
function FormInput({field, form, inline, ...props}) {
|
||||
const id = props.id || field.name;
|
||||
const label = props.label || field.name;
|
||||
const placeholder = props.placeholder || label;
|
||||
const type = props.type || 'input';
|
||||
const exactLabel = ['.', '?', '!', '؟'].includes(label.charAt(label.length - 1)) ?
|
||||
label.substring(0, label.length - 1) :
|
||||
label;
|
||||
const error = form.errors[field.name];
|
||||
const touched = form.touched[field.name];
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
{'inline ': inline},
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={classNames(
|
||||
inline ? 'block sm:inline sm:ml-4 my-2' : 'block mb-3',
|
||||
'text-sm font-medium text-gray-700'
|
||||
)}
|
||||
>
|
||||
{`${exactLabel} :`}
|
||||
</label>
|
||||
<input
|
||||
{...props}
|
||||
{...field}
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-md border border-gray-300 shadow-sm sm:text-sm',
|
||||
'focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none',
|
||||
inline ? 'block sm:inline sm:ml-4 my-2' : 'block w-full'
|
||||
)}
|
||||
/>
|
||||
{error && touched && <div className='mt-1 px-2 text-red-400 text-sm'>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInput;
|
||||
55
src/components/shared/forms/validations.js
Normal file
55
src/components/shared/forms/validations.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// validation functions
|
||||
export function required(value) {
|
||||
return (value != null) ? undefined : 'وارد کردن این فیلد الزامی است.';
|
||||
}
|
||||
|
||||
export function number(value) {
|
||||
return (value != null) ? 'وارد کردن این فیلد الزامی است.' :
|
||||
isNaN(Number(value)) ?
|
||||
'لطفا یک عدد وارد نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
|
||||
export function email(value) {
|
||||
return !value ?
|
||||
'وارد کردن این فیلد الزامی است.' :
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) ?
|
||||
'لطفا یک ایمیل معتبر وارد نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
|
||||
export function mobile(value) {
|
||||
return !value ?
|
||||
'وارد کردن این فیلد الزامی است.' :
|
||||
!/^[0][9][0-9]{9}$/i.test(value) ?
|
||||
'لطفا یک شماره تلفن همراه معتبر با حروف انگلیسی وارد نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
|
||||
export function password(value) {
|
||||
return !value ?
|
||||
'وارد کردن این فیلد الزامی است.' :
|
||||
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/i.test(value) ?
|
||||
'لطفا یک رمزعبور دارای حداقل 8 کاراکتر و ترکیبی از حروف انگلیسی و اعداد وارد نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
|
||||
export function smsKey(value) {
|
||||
return !value ?
|
||||
'وارد کردن این فیلد الزامی است.' :
|
||||
!/^[0-9]{6}$/i.test(value) ?
|
||||
'لطفا کد 6 رقمی پیامک شده به شماره خود را وارد نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
|
||||
export function gender(value) {
|
||||
return !['M', 'F'].includes(value) ? 'وارد کردن این فیلد الزامی است.' : undefined;
|
||||
}
|
||||
|
||||
export function persian(value) {
|
||||
return !value ?
|
||||
'وارد کردن این فیلد الزامی است.' :
|
||||
/[^آابپتثجچحخدذرزژسشصضطظعغفقکگلمنوهی ئء]/.test(value) ?
|
||||
'لطفا از حروف فارسی استفاده نمایید.' :
|
||||
undefined;
|
||||
}
|
||||
24
src/components/shared/loader.js
Normal file
24
src/components/shared/loader.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {ReactComponent as Spinner} from './spinner.svg';
|
||||
import './spinner.css';
|
||||
|
||||
function Loader(props) {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center h-screen text-center'>
|
||||
<Spinner width='70' className='spinner'/>
|
||||
<div className='mt-5'>
|
||||
{props.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loader.defaultProps = {
|
||||
content: 'لطفا منتظر بمانید.'
|
||||
};
|
||||
|
||||
Loader.propTypes = {
|
||||
content: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
67
src/components/shared/modal.js
Normal file
67
src/components/shared/modal.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import {Fragment} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function Modal(props) {
|
||||
if (!props.showModal) return null;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={
|
||||
'justify-center items-center flex overflow-x-hidden overflow-y-auto fixed \
|
||||
inset-0 z-50 outline-none focus:outline-none'
|
||||
}
|
||||
>
|
||||
<div className='relative w-auto my-6 mx-auto w-4/5 max-w-6xl'>
|
||||
<div
|
||||
className={
|
||||
'border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white \
|
||||
outline-none focus:outline-none'
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={
|
||||
'absolute top-0 left-0 z-10 p-1 mr-auto bg-transparent border-0 text-black opacity-50 float-left \
|
||||
text-3xl leading-none font-semibold outline-none focus:outline-none'
|
||||
}
|
||||
type='button'
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'bg-transparent text-black opacity-50 h-6 w-6 text-2xl block outline-none focus:outline-none'
|
||||
}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
{props.header && (
|
||||
<div className='flex items-start justify-between p-5 border-b border-solid border-blueGray-200 rounded-t'>
|
||||
<p className='text-2xl font-medium'>{props.header}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className='relative p-6 flex-auto'>
|
||||
<p className='my-4 text-blueGray-500 text-lg leading-relaxed'>
|
||||
{props.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-end p-4 border-t border-solid border-blueGray-200 rounded-b'>
|
||||
{props.footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='opacity-75 fixed inset-0 z-40 bg-black'/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Modal.propTypes = {
|
||||
showModal: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
header: PropTypes.node,
|
||||
body: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
51
src/components/shared/segment.js
Normal file
51
src/components/shared/segment.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function Segment(props) {
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={classNames(
|
||||
'm-4 p-4 overflow-y-auto',
|
||||
'border-2 rounded shadow-sm',
|
||||
getBorderClass(props.borderWeight, props.color, props.colorWeight),
|
||||
getTextAlignClass(props.textAlign),
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTextAlignClass(textAlign) {
|
||||
return `text-${textAlign}`;
|
||||
}
|
||||
|
||||
function getBorderClass(borderWeight, color, colorWeight) {
|
||||
const borderClass = borderWeight === 1 ? 'border' : `border-${borderWeight}`;
|
||||
const borderColorClass = color ?
|
||||
colorWeight ? `border-${color}-${colorWeight}` : `border-${color}-${colorWeight}` :
|
||||
'border-gray-300';
|
||||
return `${borderClass} ${borderColorClass}`;
|
||||
}
|
||||
|
||||
Segment.defaultProps = {
|
||||
textAlign: 'start',
|
||||
borderWeight: 1
|
||||
};
|
||||
|
||||
Segment.propTypes = {
|
||||
id: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
colorWeight: PropTypes.number,
|
||||
borderWeight: PropTypes.number.isRequired,
|
||||
textAlign: PropTypes.oneOf(['start', 'center', 'end']).isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node)
|
||||
]),
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Segment;
|
||||
44
src/components/shared/spinner.css
Normal file
44
src/components/shared/spinner.css
Normal file
@@ -0,0 +1,44 @@
|
||||
svg.spinner{
|
||||
animation: 2s linear infinite spinner-animation;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
@keyframes spinner-animation {
|
||||
0% {
|
||||
transform: rotateZ(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateZ(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
svg.spinner circle {
|
||||
animation: 1.4s ease-in-out infinite both spinner-circle-animation;
|
||||
display: block;
|
||||
fill: transparent;
|
||||
stroke: #2f3d4c;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 283;
|
||||
stroke-dashoffset: 280;
|
||||
stroke-width: 8px;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
@keyframes spinner-circle-animation {
|
||||
0%,
|
||||
25% {
|
||||
stroke-dashoffset: 280;
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
50%,
|
||||
75% {
|
||||
stroke-dashoffset: 75;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: 280;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
3
src/components/shared/spinner.svg
Normal file
3
src/components/shared/spinner.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="50" r="45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 104 B |
10
src/components/shared/use-user.js
Normal file
10
src/components/shared/use-user.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {useUser} from '../../hooks/user';
|
||||
|
||||
export function UseUser(props) {
|
||||
return props.children(useUser());
|
||||
}
|
||||
|
||||
UseUser.propTypes = {
|
||||
children: PropTypes.func.isRequired
|
||||
};
|
||||
19
src/components/students-count/container.js
Normal file
19
src/components/students-count/container.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import StudentsCountChart from './students-count-chart';
|
||||
import {useStudentsCountData} from '../../hooks/student';
|
||||
import Loader from '../shared/loader';
|
||||
import Error from '../shared/error';
|
||||
|
||||
function StudentsCountContainer() {
|
||||
const {isLoading, isError, data, error} = useStudentsCountData();
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
if (isError) return <Error error={error} />;
|
||||
|
||||
return (
|
||||
<div className='relative mx-auto h-[45vh] w-[80vw] sm:h-[60vh] md:h-[80vh]'>
|
||||
<StudentsCountChart studentsData={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudentsCountContainer;
|
||||
12
src/components/students-count/index.js
Normal file
12
src/components/students-count/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import StudentsCountContainer from './container';
|
||||
|
||||
function StudentsCountPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<StudentsCountContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudentsCountPage;
|
||||
93
src/components/students-count/students-count-chart.js
Normal file
93
src/components/students-count/students-count-chart.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import {Chart as ChartJS, defaults, registerables} from 'chart.js';
|
||||
import {Line} from 'react-chartjs-2';
|
||||
import PropTypes from 'prop-types';
|
||||
import jalaliFormat from 'date-fns-jalali/format/index.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
function StudentsCountChart(props) {
|
||||
return (
|
||||
<Line
|
||||
options={options}
|
||||
data={{
|
||||
datasets: [
|
||||
{
|
||||
label: 'تعداد کاربرها',
|
||||
data: props.studentsData.map(({date: x, count: y}) => ({x, y})),
|
||||
fill: false,
|
||||
borderColor: 'blue'
|
||||
}
|
||||
]
|
||||
}}
|
||||
type={'line'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ChartJS.register(...registerables);
|
||||
defaults.font.family = 'IRANSANS';
|
||||
defaults.font.size = 16;
|
||||
|
||||
export const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
margin: {
|
||||
left: 5
|
||||
},
|
||||
padding: {
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 1,
|
||||
hoverRadius: 3
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
text: 'تعداد داوطلبان پنل فلش کارت',
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (TooltipItems) {
|
||||
return TooltipItems.map(tootltipItem =>
|
||||
jalaliFormat(new Date(tootltipItem.raw.x), 'yyyy MMMM dd')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'day',
|
||||
stepSize: 30,
|
||||
tooltipFormat: 'dd-MM-yyyy',
|
||||
displayFormats: {
|
||||
quarter: 'MMM YYYY'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'روز'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value, index, ticks) => jalaliFormat(new Date(ticks[index].value), 'd MMMM')
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
StudentsCountChart.propTypes = {
|
||||
studentsData: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default StudentsCountChart;
|
||||
15
src/components/utils/fetch.js
Normal file
15
src/components/utils/fetch.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import {ERROR, INIT, LOADING, OK} from '../../redux/constants';
|
||||
|
||||
export function fetchData(fetchFunction) {
|
||||
fetchFunction();
|
||||
}
|
||||
|
||||
export function mergeStatuses(statuses) {
|
||||
let isOK = true;
|
||||
for (let i = 0; i < statuses.length; i++) {
|
||||
if (statuses[i] === INIT) return INIT;
|
||||
if (statuses[i] === ERROR) return ERROR;
|
||||
isOK = isOK && (statuses[i] === OK);
|
||||
}
|
||||
return isOK ? OK : LOADING;
|
||||
}
|
||||
1
src/components/utils/index.js
Normal file
1
src/components/utils/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export const BLANK_TEXT = '---';
|
||||
14
src/hooks/auth.js
Normal file
14
src/hooks/auth.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import {useMutation, useQueryClient} from 'react-query';
|
||||
import {ACCESS_TOKEN_KEY, fetchToken, REFRESH_TOKEN_KEY} from '../api/auth';
|
||||
|
||||
export function useLoginMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function onSuccess({access, refresh}) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||
queryClient.invalidateQueries('user');
|
||||
}
|
||||
|
||||
return useMutation(fetchToken, {onSuccess});
|
||||
}
|
||||
0
src/hooks/index.js
Normal file
0
src/hooks/index.js
Normal file
6
src/hooks/order.js
Normal file
6
src/hooks/order.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import {useQuery} from 'react-query';
|
||||
import {fetchOrderData} from '../api/order';
|
||||
|
||||
export function useOrdersData() {
|
||||
return useQuery('orders-data', fetchOrderData);
|
||||
}
|
||||
7
src/hooks/query-params.js
Normal file
7
src/hooks/query-params.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import {useLocation} from 'react-router-dom';
|
||||
|
||||
function useQueryParams() {
|
||||
return Object.fromEntries(new URLSearchParams(useLocation().search));
|
||||
}
|
||||
|
||||
export default useQueryParams;
|
||||
6
src/hooks/student.js
Normal file
6
src/hooks/student.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import {useQuery} from 'react-query';
|
||||
import {fetchStudentsCountData} from '../api/student';
|
||||
|
||||
export function useStudentsCountData() {
|
||||
return useQuery('users-data', fetchStudentsCountData);
|
||||
}
|
||||
7
src/hooks/user.js
Normal file
7
src/hooks/user.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import {useQuery} from 'react-query';
|
||||
import {fetchUser} from '../api/user';
|
||||
|
||||
export function useUser() {
|
||||
const data = useQuery('user', fetchUser, {staleTime: 5 * 60 * 1000, retry: false});
|
||||
return {...data, isAuthenticated: data.status === 'success'};
|
||||
}
|
||||
41
src/index.css
Normal file
41
src/index.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
b, strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#question ol, #question ul,
|
||||
#answer ol, #answer ul {
|
||||
@apply list-disc list-inside;
|
||||
@apply ps-4 m-2;
|
||||
}
|
||||
|
||||
#question table, #question img,
|
||||
#answer table, #answer img {
|
||||
@apply mx-auto;
|
||||
}
|
||||
|
||||
#question table tr, #question table td,
|
||||
#answer table tr, #answer table td {
|
||||
@apply border-collapse border border-black;
|
||||
@apply text-center p-1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
33
src/index.js
Normal file
33
src/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {QueryClient, QueryClientProvider} from 'react-query';
|
||||
import {ReactQueryDevtools} from 'react-query/devtools';
|
||||
import {ToastProvider} from 'react-toast-notifications';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider autoDismiss>
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</React.StrictMode>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
13
src/reportWebVitals.js
Normal file
13
src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
9
src/setupTests.js
Normal file
9
src/setupTests.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
|
||||
Enzyme.configure({adapter: new Adapter()});
|
||||
Reference in New Issue
Block a user