start
This commit is contained in:
18
src/components/card/answer.js
Normal file
18
src/components/card/answer.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function Answer(props) {
|
||||
return (
|
||||
<Segment id='answer' color={props.color} colorWeight={300} borderWeight={2}>
|
||||
<span className='font-semibold'>جواب:</span>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Answer.propTypes = {
|
||||
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Answer;
|
||||
55
src/components/card/card.js
Normal file
55
src/components/card/card.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import OptionContainer from './option-container';
|
||||
import Question from './question';
|
||||
import Answer from './answer';
|
||||
import Reviews from './reviews';
|
||||
import Header from './header';
|
||||
import Footer from './footer';
|
||||
import { CORRECT_COLOR, DEFAULT_COLOR } from './utils';
|
||||
import { updateStudentCardRate } from '../../redux/actions/student-card';
|
||||
|
||||
function Card(props) {
|
||||
const { card } = props;
|
||||
const [color, setColor] = useState(DEFAULT_COLOR);
|
||||
const initialRate = props.card.studentCard?.rate || 0;
|
||||
const [rate, setRate] = useState(initialRate);
|
||||
|
||||
useEffect(async () => {
|
||||
if (rate !== initialRate && card.studentCard) {
|
||||
await props.updateStudentCardRate(card.studentCard.id, rate);
|
||||
}
|
||||
}, [rate]);
|
||||
|
||||
return (
|
||||
<div className='mb-20'>
|
||||
<Header chapter={props.chapter} lessonName={props.card.lesson.name} />
|
||||
<Question
|
||||
cardId={card.id}
|
||||
question={card.question}
|
||||
color={color}
|
||||
rate={rate}
|
||||
setRate={setRate}
|
||||
/>
|
||||
<OptionContainer
|
||||
cardId={card.id}
|
||||
options={card.options}
|
||||
setColor={setColor}
|
||||
rate={rate}
|
||||
rateUpdated={rate !== initialRate}
|
||||
/>
|
||||
{color !== DEFAULT_COLOR && <Reviews color={color} />}
|
||||
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
|
||||
<Footer isAnyOptionSelected={color !== DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
card: PropTypes.object.isRequired,
|
||||
chapter: PropTypes.object.isRequired,
|
||||
updateStudentCardRate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { updateStudentCardRate })(Card);
|
||||
32
src/components/card/container.js
Normal file
32
src/components/card/container.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Card from './card';
|
||||
import Error from './error';
|
||||
import MultiFetcher from '../shared/fetches/multi-fetcher';
|
||||
import { fetchNextCard } from '../../redux/actions/card';
|
||||
import { fetchChapter } from '../../redux/actions/chapter';
|
||||
|
||||
function CardsContainer() {
|
||||
const { chapterId, cardType } = useParams();
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
const priority = urlSearchParams.get('priority');
|
||||
const rateType = urlSearchParams.get('rate-type');
|
||||
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : null;
|
||||
|
||||
return (
|
||||
<MultiFetcher
|
||||
actions={[
|
||||
fetchNextCard.bind(null, chapterId, cardType, priority, minRate),
|
||||
fetchChapter.bind(null, chapterId, priority, minRate)
|
||||
]}
|
||||
stateSelectors={{
|
||||
card: state => state.card,
|
||||
chapter: state => state.chapter
|
||||
}}
|
||||
renderError={error => <Error error={error} />}
|
||||
>
|
||||
{({ card, chapter }) => <Card card={card} chapter={chapter} />}
|
||||
</MultiFetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardsContainer;
|
||||
80
src/components/card/error.js
Normal file
80
src/components/card/error.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import ErrorComponent from '../shared/error';
|
||||
|
||||
function Error(props) {
|
||||
const { cardType } = useParams();
|
||||
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
const priority = urlSearchParams.get('priority');
|
||||
const rateType = urlSearchParams.get('rate-type');
|
||||
|
||||
const { error } = props;
|
||||
const backURL = `/decks?${urlSearchParams.toString()}`;
|
||||
|
||||
if (error.card && error.card.status === 404) {
|
||||
switch (cardType) {
|
||||
case 'review':
|
||||
return (
|
||||
<Segment>
|
||||
<p className='my-4 text-center text-xl'>
|
||||
<span>فلش کارت </span>
|
||||
<span className='font-bold'>{'مروری '}</span>
|
||||
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
|
||||
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
|
||||
<span> دیگری یافت نشد.</span>
|
||||
</p>
|
||||
<div className='my-3 flex flex-row justify-center'>
|
||||
<NavLink to={backURL} color='red' content='بازگشت' />
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
|
||||
case 'new':
|
||||
return (
|
||||
<Segment>
|
||||
<p className='text-center'>
|
||||
<span>فلش کارت </span>
|
||||
<span className='font-bold'>{'جدید '}</span>
|
||||
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
|
||||
<span> دیگری یافت نشد.</span>
|
||||
</p>
|
||||
<div className='my-3 flex flex-col items-center'>
|
||||
<NavLink to={backURL} color='red' content='بازگشت' />
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
|
||||
case 'archived':
|
||||
return (
|
||||
<Segment>
|
||||
<p className='text-center'>
|
||||
<span>فلش کارت </span>
|
||||
<span className='font-bold'>{'آرشیو شده '}</span>
|
||||
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
|
||||
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
|
||||
<span> دیگری یافت نشد.</span>
|
||||
</p>
|
||||
<div className='my-3 flex flex-col items-center gap-3'>
|
||||
<NavLink to={backURL} color='red' content='بازگشت' />
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
function _(text) {
|
||||
return !text ? '' : { starred: 'ستاره دار', active: 'فعال', high: 'ضروری ' }[text];
|
||||
}
|
||||
|
||||
Error.propTypes = {
|
||||
error: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default Error;
|
||||
98
src/components/card/footer.js
Normal file
98
src/components/card/footer.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { archiveStudentCard } from '../../redux/actions/student-card';
|
||||
import ConfirmModal from '../shared/confirm-modal';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function Footer(props) {
|
||||
const history = useHistory();
|
||||
const { chapterId, cardType } = useParams();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
let previousURL = `/reviews/last/ch-${chapterId}/${cardType}`;
|
||||
let nextURL = `/cards/ch-${chapterId}/${cardType}`;
|
||||
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
|
||||
if (urlSearchParamsSTR) {
|
||||
previousURL += `?${urlSearchParamsSTR}`;
|
||||
nextURL += `?${urlSearchParamsSTR}`;
|
||||
}
|
||||
|
||||
const archiveText = props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت';
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ConfirmModal
|
||||
showModal={showConfirmModal}
|
||||
content={
|
||||
<p>
|
||||
<span>آیا از </span>
|
||||
<span className='font-medium'>{archiveText}</span>
|
||||
<span> این فلش کارت مطمئن هستید؟</span>
|
||||
</p>
|
||||
}
|
||||
onConfirm={async () => {
|
||||
await props.archiveStudentCard();
|
||||
history.push(nextURL);
|
||||
}}
|
||||
onCancel={() => setShowConfirmModal(false)}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
|
||||
}
|
||||
>
|
||||
{props.isAnyOptionSelected ? (
|
||||
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
|
||||
بازگشت
|
||||
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
|
||||
</Button>
|
||||
) : (
|
||||
<NavLink to={previousURL} color='red' className='m-2'>
|
||||
قبلی
|
||||
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
|
||||
</NavLink>
|
||||
)}
|
||||
{props.showArchive && (
|
||||
<Button
|
||||
color={props.isPermanentArchive ? 'indigo' : 'blue'}
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
className='m-2'
|
||||
>
|
||||
{props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت'}
|
||||
</Button>
|
||||
)}
|
||||
<NavLink
|
||||
replace
|
||||
to={nextURL}
|
||||
color='green'
|
||||
textColor='white'
|
||||
disabled={!props.isAnyOptionSelected}
|
||||
className='m-2'
|
||||
>
|
||||
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
|
||||
بعدی
|
||||
</NavLink>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Footer.propTypes = {
|
||||
isAnyOptionSelected: PropTypes.bool.isRequired,
|
||||
showArchive: PropTypes.bool.isRequired,
|
||||
archiveStudentCard: PropTypes.func.isRequired,
|
||||
isPermanentArchive: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps({ review }, { showArchive }) {
|
||||
return {
|
||||
isPermanentArchive: !!review.data.studentCard && review.data.studentCard.status === 1,
|
||||
showArchive: showArchive && !!review.data.studentCard && review.data.studentCard.status !== 2
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { archiveStudentCard })(Footer);
|
||||
38
src/components/card/header.js
Normal file
38
src/components/card/header.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Segment from '../shared/segment';
|
||||
import PropTypes from 'prop-types';
|
||||
import useQueryParams from '../../hooks/query-params';
|
||||
|
||||
function Header(props) {
|
||||
const { cardType } = useParams();
|
||||
const { priority } = useQueryParams();
|
||||
|
||||
return (
|
||||
<Segment textAlign='center' color={color[cardType]} backgroundColor={color[cardType]}>
|
||||
<div className='flex flex-wrap items-center justify-around gap-3 xl:justify-between'>
|
||||
<div>
|
||||
<span>فلش کارتهای </span>
|
||||
{priority && <span className='font-medium text-red-600'>{_[priority] + ' '}</span>}
|
||||
<span className={`font-medium text-${color[cardType]}-600`}>{_[cardType]}</span>
|
||||
<span> درس </span>
|
||||
<span className='font-medium'>{props.lessonName}</span>
|
||||
<span> بخش </span>
|
||||
<span className='font-medium'>{props.chapter.name}</span>
|
||||
</div>
|
||||
<div className={`rounded-full bg-${color[cardType]}-500 py-1 px-2.5 text-white`}>
|
||||
<span className='font-medium'>{props.chapter[`${cardType}CardCount`]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
const _ = { review: 'مروری', new: 'جدید', archived: 'آرشیو شده', high: 'ضروری ' };
|
||||
const color = { review: 'blue', new: 'green', archived: 'yellow' };
|
||||
|
||||
Header.propTypes = {
|
||||
lessonName: PropTypes.string.isRequired,
|
||||
chapter: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default Header;
|
||||
26
src/components/card/icons/hide.js
Normal file
26
src/components/card/icons/hide.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { LightBulbIcon as OutlineLightBulbIcon } from '@heroicons/react/outline';
|
||||
import { LightBulbIcon as SolidLightBulbIcon } from '@heroicons/react/solid';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function HideIcon({ hide, onUpdate }) {
|
||||
return hide ? (
|
||||
<OutlineLightBulbIcon
|
||||
width={30}
|
||||
onClick={onUpdate}
|
||||
className='text-gray-300 hover:text-gray-400'
|
||||
/>
|
||||
) : (
|
||||
<SolidLightBulbIcon
|
||||
width={30}
|
||||
onClick={onUpdate}
|
||||
className='text-yellow-300 hover:text-yellow-400'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
HideIcon.propTypes = {
|
||||
hide: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default HideIcon;
|
||||
22
src/components/card/icons/index.js
Normal file
22
src/components/card/icons/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import StarIcon from './star';
|
||||
import HideIcon from './hide';
|
||||
import ReportIcon from './report';
|
||||
|
||||
function Icons({ cardId, rate, setRate }) {
|
||||
return (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{rate >= 0 && <StarIcon star={rate > 0} onUpdate={() => setRate(rate => 1 - rate)} />}
|
||||
{rate <= 0 && <HideIcon hide={rate < 0} onUpdate={() => setRate(rate => -1 - rate)} />}
|
||||
<ReportIcon cardId={cardId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Icons.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
rate: PropTypes.number,
|
||||
setRate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Icons;
|
||||
27
src/components/card/icons/report.js
Normal file
27
src/components/card/icons/report.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'react';
|
||||
import ReportModal from '../report-modal';
|
||||
|
||||
function ReportIcon({ cardId }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExclamationIcon
|
||||
width={30}
|
||||
onClick={() => setShowModal(!showModal)}
|
||||
className='text-orange-400 hover:text-orange-500'
|
||||
/>
|
||||
{showModal && (
|
||||
<ReportModal cardId={cardId} showModal={showModal} onClose={() => setShowModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReportIcon.propTypes = {
|
||||
cardId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default ReportIcon;
|
||||
22
src/components/card/icons/star.js
Normal file
22
src/components/card/icons/star.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StarIcon as OutlineStarIcon } from '@heroicons/react/outline';
|
||||
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function StarIcon({ star, onUpdate }) {
|
||||
return star ? (
|
||||
<SolidStarIcon
|
||||
width={30}
|
||||
onClick={onUpdate}
|
||||
className='text-yellow-300 hover:text-yellow-400'
|
||||
/>
|
||||
) : (
|
||||
<OutlineStarIcon width={30} onClick={onUpdate} className='text-gray-300 hover:text-gray-400' />
|
||||
);
|
||||
}
|
||||
|
||||
StarIcon.propTypes = {
|
||||
star: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default StarIcon;
|
||||
12
src/components/card/index.js
Normal file
12
src/components/card/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import CardsContainer from './container';
|
||||
|
||||
function CardPage() {
|
||||
return (
|
||||
<MainLayout withFooter>
|
||||
<CardsContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardPage;
|
||||
60
src/components/card/option-container.js
Normal file
60
src/components/card/option-container.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Option from './option';
|
||||
import { createReview } from '../../redux/actions/review';
|
||||
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
|
||||
import { updateStudentCardRate } from '../../redux/actions/student-card';
|
||||
|
||||
class OptionContainer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedOptionId: null,
|
||||
options: [...props.options, { id: 0, text: 'نمی دانم', isCorrect: false }]
|
||||
};
|
||||
}
|
||||
|
||||
handleSelect = async optionId => {
|
||||
if (this.state.selectedOptionId !== null) return null;
|
||||
this.setState({ selectedOptionId: optionId });
|
||||
if (optionId !== 0) this.setState({ options: [...this.props.options] });
|
||||
const { payload: review } = await this.props.createReview(this.props.cardId, optionId);
|
||||
if (this.props.rateUpdated) {
|
||||
await this.props.updateStudentCardRate(review.studentCard.id, this.props.rate);
|
||||
}
|
||||
this.setState({ selectedOptionId: optionId });
|
||||
this.props.setColor(
|
||||
optionId ? (review.status === 1 ? CORRECT_COLOR : WRONG_COLOR) : DONT_KNOW_COLOR
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id='options' className='space-y-3 px-4'>
|
||||
{this.state.options.map((option, index) => (
|
||||
<Option
|
||||
key={option.id}
|
||||
option={option}
|
||||
index={index}
|
||||
isAnyOptionSelected={this.state.selectedOptionId !== null}
|
||||
isSelectedOption={this.state.selectedOptionId === option.id}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OptionContainer.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
options: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
setColor: PropTypes.func.isRequired,
|
||||
createReview: PropTypes.func.isRequired,
|
||||
rate: PropTypes.number,
|
||||
rateUpdated: PropTypes.bool.isRequired,
|
||||
updateStudentCardRate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { createReview, updateStudentCardRate })(OptionContainer);
|
||||
79
src/components/card/option.js
Normal file
79
src/components/card/option.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/solid';
|
||||
import Button from '../shared/buttons/button';
|
||||
|
||||
function Option(props) {
|
||||
const { option, isAnyOptionSelected, isSelectedOption } = props;
|
||||
const color =
|
||||
isAnyOptionSelected && option.isCorrect
|
||||
? 'green'
|
||||
: isAnyOptionSelected && isSelectedOption && option.id
|
||||
? 'red'
|
||||
: option.id
|
||||
? 'gray'
|
||||
: 'yellow';
|
||||
|
||||
return (
|
||||
<Button
|
||||
id={`option ${option.id}`}
|
||||
outline={!isAnyOptionSelected}
|
||||
color={color}
|
||||
colorWeight={300}
|
||||
borderWeight={2}
|
||||
disabled={isAnyOptionSelected}
|
||||
textColor='gray'
|
||||
textColorWeight='600'
|
||||
size='xl'
|
||||
justifyContent={option.id ? 'start' : 'between'}
|
||||
onClick={() => props.onSelect(option.id)}
|
||||
className={classNames('w-full', { [`border-2 border-${color}-300`]: isAnyOptionSelected })}
|
||||
>
|
||||
<span className={classNames('flex', { 'mx-auto': !option.id })}>
|
||||
{option.id !== 0 && (
|
||||
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
|
||||
)}
|
||||
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
|
||||
{option.text}
|
||||
</span>
|
||||
</span>
|
||||
{isSelectedOption &&
|
||||
(!option.id ? (
|
||||
<ExclamationIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`shrink-0 stroke-current text-yellow-600`}
|
||||
/>
|
||||
) : option.isCorrect ? (
|
||||
<CheckIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`shrink-0 rounded-lg
|
||||
border-2 border-green-500
|
||||
stroke-current text-green-500 ms-auto`}
|
||||
/>
|
||||
) : (
|
||||
<XIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={classNames(
|
||||
`shrink-0 rounded-lg
|
||||
border-2 border-red-500
|
||||
stroke-current text-red-500 ms-auto`
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
Option.propTypes = {
|
||||
option: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isAnyOptionSelected: PropTypes.bool.isRequired,
|
||||
isSelectedOption: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Option;
|
||||
32
src/components/card/question.js
Normal file
32
src/components/card/question.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import { DEFAULT_COLOR } from './utils';
|
||||
import Icons from './icons';
|
||||
|
||||
function Question(props) {
|
||||
return (
|
||||
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
|
||||
<div>
|
||||
<div className='mb-4 flex w-full items-center '>
|
||||
<div className='flex-1 font-semibold'>سوال:</div>
|
||||
<div className='col-span-1'>
|
||||
<Icons cardId={props.cardId} rate={props.rate} setRate={props.setRate} />
|
||||
</div>
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.question }} />
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Question.defaultProps = { color: DEFAULT_COLOR };
|
||||
|
||||
Question.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
rate: PropTypes.number,
|
||||
setRate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Question;
|
||||
96
src/components/card/report-modal.js
Normal file
96
src/components/card/report-modal.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../shared/modal';
|
||||
import FormSelect from '../shared/forms/select';
|
||||
import FormTextArea from '../shared/forms/textarea';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { required } from '../shared/forms/validations';
|
||||
import { createReport } from '../../redux/actions/report';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { useState } from 'react';
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function ReportModal(props) {
|
||||
const [error, setError] = useState('');
|
||||
const { addToast } = useToasts();
|
||||
|
||||
async function submit(values) {
|
||||
try {
|
||||
setError('');
|
||||
await props.createReport(values);
|
||||
addToast('با تشکر، گزارش خطای شما ثبت گردید.', { appearance: 'success' });
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
setError(e.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
showModal={props.showModal}
|
||||
onClose={props.onClose}
|
||||
header='فرم گزارش خطا'
|
||||
body={
|
||||
<Formik initialValues={{ card: props.cardId, type: '', description: '' }} onSubmit={submit}>
|
||||
<Form id='report-form' className='flex flex-col gap-3'>
|
||||
{error && (
|
||||
<Segment
|
||||
color='red'
|
||||
backgroundColor='red'
|
||||
className='text-center font-medium text-red-500'
|
||||
>
|
||||
<p>{error}</p>
|
||||
<p>لطفا مدتی بعد مجددا تلاش کنید.</p>
|
||||
</Segment>
|
||||
)}
|
||||
<input type='hidden' id='card' name='card' />
|
||||
<Field
|
||||
required
|
||||
name='type'
|
||||
label='نوع خطا'
|
||||
component={FormSelect}
|
||||
validate={required}
|
||||
options={types}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='description'
|
||||
label='توضیحات'
|
||||
component={FormTextArea}
|
||||
validate={required}
|
||||
rows={5}
|
||||
/>
|
||||
</Form>
|
||||
</Formik>
|
||||
}
|
||||
footer={
|
||||
<div className='mx-3 flex justify-between gap-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
form='report-form'
|
||||
color='green'
|
||||
content='ثبت گزارش'
|
||||
className='grow'
|
||||
/>
|
||||
<Button color='red' content='بازگشت' onClick={props.onClose} className='grow' />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const types = [
|
||||
{ name: 'Scientific', text: 'علمی', value: 'Scientific' },
|
||||
{ name: 'Typo', text: 'نگارشی', value: 'Typo' },
|
||||
{ name: 'Other', text: 'سایر', value: 'Other' }
|
||||
];
|
||||
|
||||
ReportModal.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
showModal: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
createReport: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { createReport })(ReportModal);
|
||||
77
src/components/card/reviews.js
Normal file
77
src/components/card/reviews.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/solid';
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function Reviews(props) {
|
||||
const { color, studentCard } = props;
|
||||
return (
|
||||
<Segment id='history' color={color} colorWeight={300} borderWeight={2}>
|
||||
<div className='flex flex-wrap justify-around gap-4'>
|
||||
<p>
|
||||
<span className='font-medium'>شناسه فلش کارت: </span>
|
||||
{studentCard.card}
|
||||
</p>
|
||||
{studentCard.status > 0 && (
|
||||
<p>
|
||||
<span className='font-medium'>وضعیت: </span>
|
||||
{statuses[studentCard.status]}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className='font-medium'>مرور شماره: </span>
|
||||
{studentCard.reviewCount}
|
||||
</p>
|
||||
<div className='flex flex-wrap justify-center gap-x-2 gap-y-4'>
|
||||
<span className='font-medium'>نتایج: </span>
|
||||
<div>
|
||||
{studentCard.reviews.map(review => (
|
||||
<span key={review.id} className='px-px'>
|
||||
{!review.status ? (
|
||||
<ExclamationIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`inline-flex shrink-0 stroke-current text-yellow-500`}
|
||||
/>
|
||||
) : review.status === 1 ? (
|
||||
<CheckIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`inline-flex shrink-0 rounded-lg
|
||||
border-2 border-green-500
|
||||
stroke-current text-green-500 ms-auto`}
|
||||
/>
|
||||
) : (
|
||||
<XIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={classNames(
|
||||
`inline-flex shrink-0 rounded-lg
|
||||
border-2 border-red-500
|
||||
stroke-current text-red-500 ms-auto`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = ['عادی', 'آرشیو موقت', 'آرشیو دائم'];
|
||||
|
||||
Reviews.propTypes = {
|
||||
studentCard: PropTypes.object.isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps({ review }) {
|
||||
return { studentCard: review.data.studentCard };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Reviews);
|
||||
4
src/components/card/utils.js
Normal file
4
src/components/card/utils.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const DEFAULT_COLOR = 'gray';
|
||||
export const CORRECT_COLOR = 'green';
|
||||
export const WRONG_COLOR = 'red';
|
||||
export const DONT_KNOW_COLOR = 'yellow';
|
||||
18
src/components/check-card/answer.js
Normal file
18
src/components/check-card/answer.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import { DEFAULT_COLOR } from './utils';
|
||||
|
||||
function Answer(props) {
|
||||
return (
|
||||
<Segment id='answer' color={DEFAULT_COLOR} colorWeight={300} borderWeight={2}>
|
||||
<span className='font-semibold'>جواب:</span>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Answer.propTypes = {
|
||||
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired
|
||||
};
|
||||
|
||||
export default Answer;
|
||||
30
src/components/check-card/card.js
Normal file
30
src/components/check-card/card.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Header from './header';
|
||||
import Question from './question';
|
||||
import OptionContainer from './option-container';
|
||||
import Answer from './answer';
|
||||
import Footer from './footer';
|
||||
|
||||
function Card(props) {
|
||||
const { card } = props;
|
||||
|
||||
return (
|
||||
<div className='mb-20'>
|
||||
<Header
|
||||
cardId={card.id}
|
||||
chapterName={props.card.chapter.name}
|
||||
lessonName={props.card.lesson.name}
|
||||
/>
|
||||
<Question question={card.question} />
|
||||
<OptionContainer cardId={card.id} options={card.options} />
|
||||
<Answer answer={card.answer} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
card: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default Card;
|
||||
20
src/components/check-card/container.js
Normal file
20
src/components/check-card/container.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Card from './card';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchCard } from '../../redux/actions/card';
|
||||
import Footer from './footer';
|
||||
|
||||
function CardsContainer() {
|
||||
const { cardId } = useParams();
|
||||
return (
|
||||
<Fragment>
|
||||
<Fetcher action={fetchCard.bind(null, cardId)} stateSelector={state => state.card}>
|
||||
{card => <Card card={card} />}
|
||||
</Fetcher>
|
||||
<Footer />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardsContainer;
|
||||
48
src/components/check-card/footer.js
Normal file
48
src/components/check-card/footer.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
|
||||
import { fetchCard } from '../../redux/actions/card';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
|
||||
function Footer() {
|
||||
const { cardId } = useParams();
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={
|
||||
'align-center fixed bottom-0 left-0 flex w-full flex-row justify-between bg-black bg-opacity-60'
|
||||
}
|
||||
>
|
||||
<NavLink
|
||||
replace
|
||||
to={`/cards/${parseInt(cardId) + 1}/preview`}
|
||||
color='green'
|
||||
textColor='white'
|
||||
className='m-2'
|
||||
>
|
||||
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
|
||||
بعدی
|
||||
</NavLink>
|
||||
{cardId > 1 && (
|
||||
<NavLink
|
||||
replace
|
||||
to={`/cards/${parseInt(cardId) - 1}/preview`}
|
||||
color='red'
|
||||
className='m-2'
|
||||
>
|
||||
قبلی
|
||||
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Footer.propTypes = {
|
||||
fetchCard: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { fetchCard })(Footer);
|
||||
26
src/components/check-card/header.js
Normal file
26
src/components/check-card/header.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import Segment from '../shared/segment';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function Header(props) {
|
||||
return (
|
||||
<Segment textAlign='center' color='violet' backgroundColor='violet'>
|
||||
<p>
|
||||
<span className='font-medium'>پیش نمایش </span>
|
||||
<span>فلش کارت </span>
|
||||
<span className='font-medium'>{props.cardId}</span>
|
||||
<span> درس </span>
|
||||
<span className='font-medium'>{props.lessonName}</span>
|
||||
<span> بخش </span>
|
||||
<span className='font-medium'>{props.chapterName}</span>
|
||||
</p>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
lessonName: PropTypes.string.isRequired,
|
||||
chapterName: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default Header;
|
||||
10
src/components/check-card/index.js
Normal file
10
src/components/check-card/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import MainLayout from '../layouts/main';
|
||||
import CardsContainer from './container';
|
||||
|
||||
function CardPage() {
|
||||
const { isSuperuser } = useSelector(({ user }) => user.data);
|
||||
return <MainLayout withFooter>{isSuperuser ? <CardsContainer /> : null}</MainLayout>;
|
||||
}
|
||||
|
||||
export default CardPage;
|
||||
36
src/components/check-card/option-container.js
Normal file
36
src/components/check-card/option-container.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Option from './option';
|
||||
|
||||
class OptionContainer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
options: [
|
||||
...props.options,
|
||||
{
|
||||
id: 0,
|
||||
text: 'نمی دانم',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id='options' className='space-y-3 px-4'>
|
||||
{this.state.options.map((option, index) => (
|
||||
<Option key={option.id} option={option} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OptionContainer.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
options: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default OptionContainer;
|
||||
41
src/components/check-card/option.js
Normal file
41
src/components/check-card/option.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
|
||||
|
||||
function Option(props) {
|
||||
const { option } = props;
|
||||
|
||||
const color = option.isCorrect ? CORRECT_COLOR : option.id ? WRONG_COLOR : DONT_KNOW_COLOR;
|
||||
return (
|
||||
<Button
|
||||
id={`option ${option.id}`}
|
||||
disabled
|
||||
outline={false}
|
||||
color={color}
|
||||
colorWeight={300}
|
||||
borderWeight={2}
|
||||
textColor='gray'
|
||||
textColorWeight='600'
|
||||
size='xl'
|
||||
justifyContent={option.id ? 'start' : 'between'}
|
||||
className={`w-full border-2 border-${color}-300`}
|
||||
>
|
||||
<span className={classNames('flex', { 'mx-auto': !option.id })}>
|
||||
{option.id !== 0 && (
|
||||
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
|
||||
)}
|
||||
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
|
||||
{option.text}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
Option.propTypes = {
|
||||
option: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default Option;
|
||||
20
src/components/check-card/question.js
Normal file
20
src/components/check-card/question.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import { DEFAULT_COLOR } from './utils';
|
||||
|
||||
function Question(props) {
|
||||
return (
|
||||
<Segment id='question' color={DEFAULT_COLOR} colorWeight={300} borderWeight={2}>
|
||||
<span className='font-semibold'>سوال:</span>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.question }} />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Question.defaultProps = { color: DEFAULT_COLOR };
|
||||
|
||||
Question.propTypes = {
|
||||
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired
|
||||
};
|
||||
|
||||
export default Question;
|
||||
4
src/components/check-card/utils.js
Normal file
4
src/components/check-card/utils.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const DEFAULT_COLOR = 'gray';
|
||||
export const CORRECT_COLOR = 'green';
|
||||
export const WRONG_COLOR = 'red';
|
||||
export const DONT_KNOW_COLOR = 'yellow';
|
||||
37
src/components/decks/badge.js
Normal file
37
src/components/decks/badge.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function Badge(props) {
|
||||
const { color } = props;
|
||||
if (props.number === 0) return null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={classNames(
|
||||
`bg-${color}-500 flex items-center rounded-xl px-1 py-0.5 leading-none text-white`,
|
||||
`border border-opacity-50 border-${color}-600`,
|
||||
{ 'cursor-not-allowed': !props.to }
|
||||
)}
|
||||
>
|
||||
<span className='m-1 text-xs font-medium'>{props.text}</span>
|
||||
<span className={`bg-white text-xs text-${color}-700 m-1 rounded-xl py-0.5 px-1`}>
|
||||
{props.number}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!props.to) return content;
|
||||
return <Link to={props.to}>{content}</Link>;
|
||||
}
|
||||
|
||||
Badge.defaultProps = { to: '', color: 'blue' };
|
||||
|
||||
Badge.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
number: PropTypes.number.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
90
src/components/decks/chapter-modal.js
Normal file
90
src/components/decks/chapter-modal.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../shared/modal';
|
||||
import Button from '../shared/buttons/button';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
|
||||
function ChapterModal(props) {
|
||||
const { chapter, priority, rateType } = props;
|
||||
if (!chapter) return null;
|
||||
|
||||
const noCards =
|
||||
chapter.reviewCardCount === 0 && chapter.newCardCount === 0 && chapter.archivedCardCount === 0;
|
||||
|
||||
function getURL(cardType) {
|
||||
const url = `/cards/ch-${chapter.id}/${cardType}`;
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
if (priority) urlSearchParams.set('priority', priority);
|
||||
if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType);
|
||||
const urlSearchParamsSTR = urlSearchParams.toString();
|
||||
if (!urlSearchParamsSTR) return url;
|
||||
return url + `?${urlSearchParamsSTR}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
showModal
|
||||
onClose={props.onClose}
|
||||
header='دسترسی به فلش کارتها'
|
||||
body={
|
||||
<div className='text-base'>
|
||||
<p>
|
||||
<span>شما فصل </span>
|
||||
<span className='font-medium'>{chapter.name}</span>
|
||||
<span> از درس </span>
|
||||
<span className='font-medium'>{props.lessonName}</span>
|
||||
<span> را انتخاب کرده اید.</span>
|
||||
</p>
|
||||
{noCards ? (
|
||||
<div>
|
||||
<p>تمام فلش کارت های جدید و مروری این درس را مطالعه کرده اید.</p>
|
||||
<p>مرورهای بعدی این درس در روزهای آینده فعال خواهد شد.</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
با استفاده از دکمههای زیر میتوانید به فلش کارتهای مورد نظر خود دسترسی داشته باشید.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
{chapter.reviewCardCount > 0 && (
|
||||
<NavLink to={getURL('review')} size='sm' color='red'>
|
||||
<span className='m-1 text-xs font-medium'>مروری</span>
|
||||
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-red-800'>
|
||||
{chapter.reviewCardCount}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{chapter.newCardCount > 0 && (
|
||||
<NavLink to={getURL('new')} size='sm' color='yellow'>
|
||||
<span className='align-middle text-xs font-medium'>جدید</span>
|
||||
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-yellow-800'>
|
||||
{chapter.newCardCount}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{chapter.archivedCardCount > 0 && (
|
||||
<NavLink to={getURL('archived')} size='sm' color='blue'>
|
||||
<span className='text-xs font-medium'>آرشیو شده</span>
|
||||
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-blue-800'>
|
||||
{chapter.archivedCardCount}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{noCards && <Button color='red' content='بازگشت' onClick={props.onClose} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ChapterModal.propTypes = {
|
||||
chapter: PropTypes.object,
|
||||
lessonName: PropTypes.string,
|
||||
priority: PropTypes.string,
|
||||
rateType: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ChapterModal;
|
||||
27
src/components/decks/container.js
Normal file
27
src/components/decks/container.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import StudentDecks from './student-decks';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import NoDeck from './no-deck';
|
||||
import { fetchStudentDecks } from '../../redux/actions/student-deck';
|
||||
|
||||
function DecksContainer() {
|
||||
const location = useLocation();
|
||||
const searchPrams = new URLSearchParams(location.search);
|
||||
const priority = searchPrams.get('priority');
|
||||
const rateType = searchPrams.get('rate-type');
|
||||
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1;
|
||||
|
||||
return (
|
||||
<Fetcher
|
||||
action={() => fetchStudentDecks(priority, minRate)}
|
||||
stateSelector={state => state.studentDecks}
|
||||
renderEmpty={() => <NoDeck />}
|
||||
>
|
||||
{studentDecks => (
|
||||
<StudentDecks priority={priority} rateType={rateType} studentDecks={studentDecks} />
|
||||
)}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default DecksContainer;
|
||||
14
src/components/decks/index.js
Normal file
14
src/components/decks/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import DecksContainer from './container';
|
||||
import DecksTab from './tab';
|
||||
|
||||
function DecksPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<DecksTab />
|
||||
<DecksContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DecksPage;
|
||||
27
src/components/decks/no-deck.js
Normal file
27
src/components/decks/no-deck.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Segment from '../shared/segment';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
|
||||
function NoDeck() {
|
||||
return (
|
||||
<Segment className='leading-8'>
|
||||
<p className='text-center'>داوطلب گرامی، شما هیچ بسته فلش کارت فعالی ندارید.</p>
|
||||
<p className='text-center'>
|
||||
لطفا برای خرید بسته فلش کارت مورد نظر خود به
|
||||
<Link to='/store' className='text-blue-600'>
|
||||
{' '}
|
||||
فروشگاه{' '}
|
||||
</Link>
|
||||
مراجعه کنید.
|
||||
</p>
|
||||
<p className='text-center'>
|
||||
<span>همچنین با مراجعه به فروشگاه میتوانید از </span>
|
||||
<span className='font-medium text-pink-500'>بستههای تستی رایگان</span>
|
||||
<span> مدمشاور نیز استفاده کنید.</span>
|
||||
</p>
|
||||
<NavLink to='/store' color='green' content='فروشگاه' className='mx-auto my-4' />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoDeck;
|
||||
78
src/components/decks/reset-modal/index.js
Normal file
78
src/components/decks/reset-modal/index.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ResetModalStepOne from './step-one';
|
||||
import ResetModalStepTwo from './step-two';
|
||||
import ResetModalStepSuccess from './step-success';
|
||||
import { connect } from 'react-redux';
|
||||
import { createResetRequest } from '../../../redux/actions/reset-request';
|
||||
import { fetchStudentDecks } from '../../../redux/actions/student-deck';
|
||||
import ResetModalStepError from './step-error';
|
||||
|
||||
function ResetModal(props) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [error, setError] = useState(null);
|
||||
const { itemType, itemId, itemName, onClose } = props;
|
||||
|
||||
const itemLabel = `${_[itemType]} ${itemName}`;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ResetModalStepError
|
||||
itemLabel={itemLabel}
|
||||
error={error}
|
||||
onClose={() => {
|
||||
setStep(1);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
return <ResetModalStepOne itemLabel={itemLabel} onClose={onClose} onNext={() => setStep(2)} />;
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
return (
|
||||
<ResetModalStepTwo
|
||||
onClose={onClose}
|
||||
onNext={async () => {
|
||||
try {
|
||||
await props.createResetRequest(itemType, itemId);
|
||||
setStep(0);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 0) {
|
||||
return (
|
||||
<ResetModalStepSuccess
|
||||
itemLabel={itemLabel}
|
||||
onClose={() => {
|
||||
setStep(1);
|
||||
props.fetchStudentDecks();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const _ = { chapter: 'فصل', lesson: 'درس', deck: 'بسته' };
|
||||
|
||||
ResetModal.propTypes = {
|
||||
itemType: PropTypes.oneOf(['chapter', 'lesson', 'deck']).isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
itemName: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
createResetRequest: PropTypes.func.isRequired,
|
||||
fetchStudentDecks: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { createResetRequest, fetchStudentDecks })(ResetModal);
|
||||
48
src/components/decks/reset-modal/step-error.js
Normal file
48
src/components/decks/reset-modal/step-error.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../shared/modal';
|
||||
import Button from '../../shared/buttons/button';
|
||||
|
||||
function ResetModalStepError(props) {
|
||||
return (
|
||||
<Modal
|
||||
showModal
|
||||
onClose={props.onClose}
|
||||
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
|
||||
body={
|
||||
<div className='text-base'>
|
||||
<p>
|
||||
<span>بازنشانی فلش کارت های </span>
|
||||
<span className='font-semibold'>{props.itemLabel}</span>
|
||||
<span> با </span>
|
||||
<span className='font-medium text-red-500'>خطا</span>
|
||||
<span> مواجه شد.</span>
|
||||
</p>
|
||||
<p>در صورت تکرار لطفا ادمین سایت را در جریان قرار دهید.</p>
|
||||
<div className='my-2'>
|
||||
<p>
|
||||
<span className='font-semibold'>کد خطا: </span>
|
||||
<span>{props.error.status}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-semibold'>اطلاعات خطا: </span>
|
||||
<span>{JSON.stringify(props.error.data)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button color='red' content='بازگشت' onClick={props.onClose} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ResetModalStepError.propTypes = {
|
||||
itemLabel: PropTypes.string.isRequired,
|
||||
error: PropTypes.object.isRequired,
|
||||
onClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ResetModalStepError;
|
||||
66
src/components/decks/reset-modal/step-one.js
Normal file
66
src/components/decks/reset-modal/step-one.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../shared/modal';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { InformationCircleIcon } from '@heroicons/react/outline';
|
||||
import Audio from '../../shared/audio';
|
||||
|
||||
function ResetModalStepOne(props) {
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
|
||||
function toggleShowGuide() {
|
||||
setShowGuide(showGuide => !showGuide);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
showModal
|
||||
onClose={props.onClose}
|
||||
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
|
||||
body={
|
||||
<div className='text-base'>
|
||||
<p>
|
||||
<span>شما در حال </span>
|
||||
<span className='font-semibold text-red-500'>بازنشانی </span>
|
||||
<span>فلش کارت های </span>
|
||||
<span className='font-semibold'>{props.itemLabel}</span>
|
||||
<span> هستید.</span>
|
||||
</p>
|
||||
<p>در صورت تایید تمام سوابق مرور فلش کارت های این مبحث حذف خواهد شد.</p>
|
||||
<p>
|
||||
همچنین امتیازاتی که از مطالعه فلش کارت های این بخش کسب شده از بین خواهد رفت و ممکن از
|
||||
رتبه شما در رتبه بندی هفتگی و ماهانه تغییر کند.
|
||||
</p>
|
||||
<p className='font-medium'>آیا مطمئن هستید؟</p>
|
||||
{showGuide && (
|
||||
<>
|
||||
<hr className='my-4' />
|
||||
<div id='guide'>
|
||||
<Audio src='/voices/reset-guide.mp3' caption='فایل صوتی توضیحات بازنشانی (ریست):' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className='flex justify-between'>
|
||||
<Button circular color='gray' colorWeight={400} onClick={toggleShowGuide}>
|
||||
<InformationCircleIcon width={20} height={20} />
|
||||
</Button>
|
||||
<div className='flex gap-1'>
|
||||
<Button color='green' content='بلی' onClick={props.onNext} />
|
||||
<Button color='red' content='خیر' onClick={props.onClose} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ResetModalStepOne.propTypes = {
|
||||
itemLabel: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ResetModalStepOne;
|
||||
36
src/components/decks/reset-modal/step-success.js
Normal file
36
src/components/decks/reset-modal/step-success.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../shared/modal';
|
||||
import Button from '../../shared/buttons/button';
|
||||
|
||||
function ResetModalStepSuccess(props) {
|
||||
return (
|
||||
<Modal
|
||||
showModal
|
||||
onClose={props.onClose}
|
||||
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
|
||||
body={
|
||||
<div className='text-base'>
|
||||
<p>
|
||||
<span>بازنشانی فلش کارت های </span>
|
||||
<span className='font-semibold'>{props.itemLabel}</span>
|
||||
<span> با </span>
|
||||
<span className='font-medium text-green-500'>موفقیت</span>
|
||||
<span> انجام شد.</span>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button color='red' content='بازگشت' onClick={props.onClose} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ResetModalStepSuccess.propTypes = {
|
||||
itemLabel: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ResetModalStepSuccess;
|
||||
71
src/components/decks/reset-modal/step-two.js
Normal file
71
src/components/decks/reset-modal/step-two.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../shared/modal';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormInput from '../../shared/forms/input';
|
||||
|
||||
function ResetModalStepTwo(props) {
|
||||
const [isCorrectCode, setIsCorrectCode] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
showModal
|
||||
onClose={props.onClose}
|
||||
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
|
||||
body={
|
||||
<div className='text-base'>
|
||||
<p>برای تایید 4 رقم آخر شماره تلفن همراه خود را وارد نمایید:</p>
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
initialValues={{ code: '' }}
|
||||
validate={({ code }) => {
|
||||
const isCorrect = code === props.correctCode;
|
||||
setIsCorrectCode(isCorrect);
|
||||
if (code.length !== 4) {
|
||||
return { code: '4 رقم آخر شماره تلفن همراه خود را وارد نمایید.' };
|
||||
}
|
||||
if (!isCorrect) return { code: 'کد وارد شده اشتباه است.' };
|
||||
}}
|
||||
onSubmit={props.onNext}
|
||||
>
|
||||
<Form className='grid grid-cols-1 gap-5 p-3'>
|
||||
<Field
|
||||
required
|
||||
name='code'
|
||||
label='4 رقم آخر شماره تلفن همراه'
|
||||
component={FormInput}
|
||||
maxLength={4}
|
||||
/>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
disabled={!isCorrectCode}
|
||||
color='green'
|
||||
content='تایید'
|
||||
onClick={formRef.current?.submitForm}
|
||||
/>
|
||||
<Button color='red' content='بازگشت' onClick={props.onClose} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ResetModalStepTwo.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired,
|
||||
correctCode: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps({ user }) {
|
||||
return { correctCode: user.data.mobile.slice(-4) };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ResetModalStepTwo);
|
||||
50
src/components/decks/student-deck/container.js
Normal file
50
src/components/decks/student-deck/container.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import Fetcher from '../../shared/fetches/fetcher';
|
||||
import PropTypes from 'prop-types';
|
||||
import StudentDeck from './student-deck';
|
||||
import Loader from '../../shared/loader';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchStudentDeck } from '../../../redux/actions/student-deck';
|
||||
|
||||
function StudentDeckContainer({ studentDeckId, onSelectChapter, onRefreshSelected }) {
|
||||
const location = useLocation();
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
const rateType = urlSearchParams.get('rate-type');
|
||||
const priority = urlSearchParams.get('priority');
|
||||
|
||||
const action = useCallback(() => {
|
||||
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1;
|
||||
return fetchStudentDeck(studentDeckId, priority, minRate);
|
||||
}, [studentDeckId, priority, rateType]);
|
||||
|
||||
const renderLoader = useCallback(
|
||||
() => (
|
||||
<div className='border border-t-0 border-gray-500 text-center'>
|
||||
<Loader full={false} />
|
||||
</div>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fetcher action={action} stateSelector={state => state.studentDeck} renderLoader={renderLoader}>
|
||||
{studentDeck => (
|
||||
<StudentDeck
|
||||
priority={priority}
|
||||
rateType={rateType}
|
||||
studentDeck={studentDeck}
|
||||
onSelectChapter={onSelectChapter}
|
||||
onRefreshSelected={onRefreshSelected}
|
||||
/>
|
||||
)}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
StudentDeckContainer.propTypes = {
|
||||
studentDeckId: PropTypes.number.isRequired,
|
||||
onSelectChapter: PropTypes.func.isRequired,
|
||||
onRefreshSelected: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default StudentDeckContainer;
|
||||
77
src/components/decks/student-deck/student-deck.js
Normal file
77
src/components/decks/student-deck/student-deck.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Badge from '../badge';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { RefreshIcon } from '@heroicons/react/outline';
|
||||
import { compareChapter } from '../utils';
|
||||
|
||||
function StudentDeck({ priority, rateType, studentDeck, onSelectChapter, onRefreshSelected }) {
|
||||
function getURL(chapterId, cardType) {
|
||||
const url = `/cards/ch-${chapterId}/${cardType}`;
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
if (priority) urlSearchParams.set('priority', priority);
|
||||
if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType);
|
||||
const urlSearchParamsSTR = urlSearchParams.toString();
|
||||
if (!urlSearchParamsSTR) return url;
|
||||
return url + `?${urlSearchParamsSTR}`;
|
||||
}
|
||||
|
||||
return studentDeck.deck.chapters.sort(compareChapter).map((chapter, index, array) => (
|
||||
<div
|
||||
key={chapter.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onSelectChapter(chapter);
|
||||
}}
|
||||
className={classNames('border border-t-0 border-gray-500 text-center', {
|
||||
'rounded-b-lg': index === array.length - 1
|
||||
})}
|
||||
>
|
||||
<div className='relative grid grid-flow-col grid-cols-9 p-4 hover:bg-gray-100 sm:grid-flow-row'>
|
||||
<div className='row-span-3 m-auto'>({index + 1})</div>
|
||||
<div className='col-span-8 m-auto sm:col-span-3'>{chapter.name}</div>
|
||||
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
|
||||
<Badge
|
||||
text='مروری'
|
||||
number={chapter.reviewCardCount}
|
||||
to={getURL(chapter.id, 'review')}
|
||||
color='red'
|
||||
/>
|
||||
<Badge
|
||||
text='جدید'
|
||||
number={chapter.newCardCount}
|
||||
to={getURL(chapter.id, 'new')}
|
||||
color='yellow'
|
||||
/>
|
||||
</div>
|
||||
{!rateType && (
|
||||
<div className='row-span-3 m-auto font-bold'>
|
||||
<Button
|
||||
outline
|
||||
circular
|
||||
size='xs'
|
||||
color='orange'
|
||||
disabled={chapter.cardCount === chapter.newCardCount}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRefreshSelected({ id: chapter.id, type: 'chapter', name: chapter.name });
|
||||
}}
|
||||
>
|
||||
<RefreshIcon width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
StudentDeck.propTypes = {
|
||||
priority: PropTypes.string,
|
||||
rateType: PropTypes.string,
|
||||
studentDeck: PropTypes.object.isRequired,
|
||||
onSelectChapter: PropTypes.func.isRequired,
|
||||
onRefreshSelected: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default StudentDeck;
|
||||
167
src/components/decks/student-decks.js
Normal file
167
src/components/decks/student-decks.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Component, Fragment } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
MinusCircleIcon,
|
||||
PlusCircleIcon,
|
||||
RefreshIcon
|
||||
} from '@heroicons/react/outline';
|
||||
import { getStudentDeckComparator } from './utils';
|
||||
import Badge from './badge';
|
||||
import Button from '../shared/buttons/button';
|
||||
import ChapterModal from './chapter-modal';
|
||||
import ResetModal from './reset-modal';
|
||||
import StudentDeckContainer from './student-deck/container';
|
||||
|
||||
class StudentDecks extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedStudentDeck: props.studentDecks.length === 1 ? props.studentDecks[0] : null,
|
||||
selectedChapter: null,
|
||||
selectedRefreshItem: null
|
||||
};
|
||||
}
|
||||
|
||||
isSelectedStudentDeck = studentDeckId => this.state.selectedStudentDeck?.id === studentDeckId;
|
||||
|
||||
handleSelectStudentDeck = studentDeck =>
|
||||
this.setState(state => ({
|
||||
selectedStudentDeck: state.selectedStudentDeck?.id === studentDeck.id ? null : studentDeck
|
||||
}));
|
||||
|
||||
handleSelectChapter = chapter =>
|
||||
this.setState(state => ({
|
||||
selectedChapter: state.selectedChapter?.id === chapter?.id ? null : chapter
|
||||
}));
|
||||
|
||||
handleRefreshSelected = item =>
|
||||
this.setState(state => ({
|
||||
selectedRefreshItem: state.selectedRefreshItem?.id === item?.id ? null : item
|
||||
}));
|
||||
|
||||
getStudentDeckColor = studentDeck => {
|
||||
if (studentDeck.reviewCardCount !== 0) return 'red';
|
||||
return studentDeck.newCardCount !== 0 ? 'blue' : 'green';
|
||||
};
|
||||
|
||||
render() {
|
||||
const comparator = getStudentDeckComparator(this.props.priority);
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='mx-4 rounded-lg'>
|
||||
{this.props.studentDecks.sort(comparator).map(studentDeck => {
|
||||
if (this.props.priority && !studentDeck.deck.prioritized) {
|
||||
return (
|
||||
<div key={studentDeck.id} className='mb-1'>
|
||||
<div
|
||||
className={classNames(
|
||||
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
|
||||
`bg-gray-100`,
|
||||
'rounded-lg border border-black p-4 text-center'
|
||||
)}
|
||||
>
|
||||
<div className='row-span-3 m-auto'>
|
||||
<InformationCircleIcon width={25} height={25} />
|
||||
</div>
|
||||
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
|
||||
{studentDeck.deck.name}
|
||||
</div>
|
||||
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
|
||||
به زودی
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={studentDeck.id} className='mb-1'>
|
||||
<div
|
||||
className={classNames(
|
||||
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
|
||||
`bg-${this.getStudentDeckColor(studentDeck)}-100`,
|
||||
{ 'rounded-b-lg': !this.isSelectedStudentDeck(studentDeck.id) },
|
||||
'rounded-t-lg border border-black p-4 text-center'
|
||||
)}
|
||||
onClick={() => this.handleSelectStudentDeck(studentDeck)}
|
||||
>
|
||||
<div className='row-span-3 m-auto'>
|
||||
{this.isSelectedStudentDeck(studentDeck.id) ? (
|
||||
<PlusCircleIcon width={25} height={25} />
|
||||
) : (
|
||||
<MinusCircleIcon width={25} height={25} />
|
||||
)}
|
||||
</div>
|
||||
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
|
||||
{studentDeck.deck.name}
|
||||
</div>
|
||||
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
|
||||
<Badge text='مروری' number={studentDeck.reviewCardCount} color='red' />
|
||||
<Badge text='جدید' number={studentDeck.newCardCount} color='yellow' />
|
||||
</div>
|
||||
{!this.props.rateType && (
|
||||
<div className='row-span-3 m-auto font-bold'>
|
||||
<Button
|
||||
outline
|
||||
circular
|
||||
size='xs'
|
||||
color='orange'
|
||||
disabled={studentDeck.deck.cardCount === studentDeck.newCardCount}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
this.handleRefreshSelected({
|
||||
id: studentDeck.deck.id,
|
||||
type: 'deck',
|
||||
name: studentDeck.deck.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RefreshIcon width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{this.isSelectedStudentDeck(studentDeck.id) && (
|
||||
<StudentDeckContainer
|
||||
studentDeckId={studentDeck.id}
|
||||
onSelectChapter={this.handleSelectChapter}
|
||||
onRefreshSelected={this.handleRefreshSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{this.state.selectedChapter && (
|
||||
<ChapterModal
|
||||
chapter={this.state.selectedChapter}
|
||||
lessonName={this.state.selectedStudentDeck?.deck.lesson.name}
|
||||
priority={this.props.priority}
|
||||
rateType={this.props.rateType}
|
||||
onClose={() => this.handleSelectChapter(null)}
|
||||
/>
|
||||
)}
|
||||
{this.state.selectedRefreshItem && (
|
||||
<ResetModal
|
||||
itemType={this.state.selectedRefreshItem.type}
|
||||
itemId={this.state.selectedRefreshItem.id}
|
||||
itemName={this.state.selectedRefreshItem.name}
|
||||
onClose={() => this.handleRefreshSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StudentDecks.propTypes = {
|
||||
priority: PropTypes.string,
|
||||
rateType: PropTypes.string,
|
||||
studentDecks: PropTypes.array.isRequired,
|
||||
history: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default withRouter(StudentDecks);
|
||||
13
src/components/decks/tab/index.js
Normal file
13
src/components/decks/tab/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import RateTypeTab from './rate-type-tab';
|
||||
import PeriorityTab from './priority-tab';
|
||||
|
||||
function DecksTab() {
|
||||
return (
|
||||
<div className='mb-8'>
|
||||
<PeriorityTab />
|
||||
<RateTypeTab />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DecksTab;
|
||||
38
src/components/decks/tab/priority-tab.js
Normal file
38
src/components/decks/tab/priority-tab.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import ButtonGroup from '../../shared/buttons/button-group';
|
||||
import NavLink from '../../shared/buttons/nav-link';
|
||||
|
||||
function PeriorityTab() {
|
||||
const location = useLocation();
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
const currentPriority = urlSearchParams.get('priority');
|
||||
|
||||
function getLinkColor(priority) {
|
||||
return currentPriority === priority ? 'rose' : 'gray';
|
||||
}
|
||||
|
||||
function getNextURL(priority) {
|
||||
if (priority) {
|
||||
urlSearchParams.set('priority', priority);
|
||||
} else {
|
||||
urlSearchParams.delete('priority');
|
||||
}
|
||||
let url = '/decks';
|
||||
const urlSearchParamsSTR = urlSearchParams.toString();
|
||||
if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup className='mx-auto my-3 w-min'>
|
||||
<NavLink to={getNextURL('high')} size='xl' color={getLinkColor('high')}>
|
||||
ضروری
|
||||
</NavLink>
|
||||
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
|
||||
همه
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default PeriorityTab;
|
||||
41
src/components/decks/tab/rate-type-tab.js
Normal file
41
src/components/decks/tab/rate-type-tab.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import ButtonGroup from '../../shared/buttons/button-group';
|
||||
import NavLink from '../../shared/buttons/nav-link';
|
||||
|
||||
function RateTypeTab() {
|
||||
const location = useLocation();
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
const currentRateType = urlSearchParams.get('rate-type');
|
||||
|
||||
function getLinkColor(rateType) {
|
||||
return currentRateType === rateType ? 'blue' : 'gray';
|
||||
}
|
||||
|
||||
function getNextURL(rateType) {
|
||||
if (rateType) {
|
||||
urlSearchParams.set('rate-type', rateType);
|
||||
} else {
|
||||
urlSearchParams.delete('rate-type');
|
||||
}
|
||||
let url = '/decks';
|
||||
const urlSearchParamsSTR = urlSearchParams.toString();
|
||||
if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup className='mx-auto my-3 w-min'>
|
||||
<NavLink to={getNextURL('starred')} size='xl' color={getLinkColor('starred')}>
|
||||
ستاره دار
|
||||
</NavLink>
|
||||
<NavLink to={getNextURL('active')} size='xl' color={getLinkColor('active')}>
|
||||
فعال
|
||||
</NavLink>
|
||||
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
|
||||
همه
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default RateTypeTab;
|
||||
18
src/components/decks/utils.js
Normal file
18
src/components/decks/utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export function getStudentDeckComparator(priority = null) {
|
||||
return function compareStudentDeck(sd1, sd2) {
|
||||
return (
|
||||
(priority && sd2.deck.prioritized - sd1.deck.prioritized) ||
|
||||
sd2.reviewCardCount - sd1.reviewCardCount ||
|
||||
sd2.newCardCount - sd1.newCardCount ||
|
||||
sd2.deck.cardCount - sd1.deck.cardCount
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function compareChapter(ch1, ch2) {
|
||||
return (
|
||||
ch2.reviewCardCount - ch1.reviewCardCount ||
|
||||
ch2.newCardCount - ch1.newCardCount ||
|
||||
ch2.cardCount - ch1.cardCount
|
||||
);
|
||||
}
|
||||
7
src/components/default/index.js
Normal file
7
src/components/default/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
|
||||
function DefaultPage() {
|
||||
return <MainLayout loginRequired={false} />;
|
||||
}
|
||||
|
||||
export default DefaultPage;
|
||||
118
src/components/forget-password/index.js
Normal file
118
src/components/forget-password/index.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import PropTypes from 'prop-types';
|
||||
import MainLayout from '../layouts/main';
|
||||
import MobileForm from './mobile-form';
|
||||
import SMSKeyForm from '../shared/sms-key-form';
|
||||
import PasswordForm from './password-form';
|
||||
import Alert from '../shared/alert';
|
||||
import {
|
||||
checkSMSKeyRequest,
|
||||
forgetPasswordRequest,
|
||||
resetPasswordRequest
|
||||
} from '../../redux/actions/user';
|
||||
import { loginUser } from '../../redux/actions/auth';
|
||||
import { selectIsAuthenticated } from '../../redux/selectors/auth';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function ForgetPasswordPage(props) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [mobile, setMobile] = useState(null);
|
||||
const [uid, setUID] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const { addToast } = useToasts();
|
||||
if (props.isAuthenticated) return <Redirect to={DEFAULT_DECKS_ROUTE} />;
|
||||
|
||||
function renderForm(step) {
|
||||
if (step === MOBILE) {
|
||||
return (
|
||||
<MobileForm
|
||||
onSubmit={async function (mobile) {
|
||||
const {
|
||||
payload: { uid }
|
||||
} = await props.forgetPasswordRequest(mobile);
|
||||
setMobile(mobile);
|
||||
setUID(uid);
|
||||
setStep(SMS_KEY);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === SMS_KEY) {
|
||||
return (
|
||||
<SMSKeyForm
|
||||
back
|
||||
mobile={mobile}
|
||||
onSubmit={async function (smsKey) {
|
||||
const {
|
||||
payload: { token }
|
||||
} = await props.checkSMSKeyRequest(uid, smsKey);
|
||||
setToken(token);
|
||||
setStep(PASSWORD);
|
||||
}}
|
||||
onResend={async function () {
|
||||
const {
|
||||
payload: { uid }
|
||||
} = await props.forgetPasswordRequest(mobile);
|
||||
setUID(uid);
|
||||
}}
|
||||
onBack={setStep.bind(null, MOBILE)}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === PASSWORD) {
|
||||
return (
|
||||
<PasswordForm
|
||||
onSubmit={async function (password) {
|
||||
const { error } = await props.resetPasswordRequest(uid, token, password);
|
||||
if (!error) {
|
||||
addToast('رمز عبور شما با موفقیت تغییر کرد.', { appearance: 'success' });
|
||||
await props.loginUser(mobile, password);
|
||||
setStep(MOBILE);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout loginRequired={false} consultPackageRequired={false} packageRequired={false}>
|
||||
{error ? (
|
||||
<Alert content={error} className='mt-3 mb-5' />
|
||||
) : (
|
||||
<div className='mx-auto w-full sm:w-4/5 lg:w-3/5'>{renderForm(step)}</div>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [MOBILE, SMS_KEY, PASSWORD] = [0, 1, 2];
|
||||
|
||||
ForgetPasswordPage.propTypes = {
|
||||
isAuthenticated: PropTypes.bool.isRequired,
|
||||
checkSMSKeyRequest: PropTypes.func.isRequired,
|
||||
forgetPasswordRequest: PropTypes.func.isRequired,
|
||||
resetPasswordRequest: PropTypes.func.isRequired,
|
||||
loginUser: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return { isAuthenticated: selectIsAuthenticated(state) };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
checkSMSKeyRequest,
|
||||
forgetPasswordRequest,
|
||||
resetPasswordRequest,
|
||||
loginUser
|
||||
})(ForgetPasswordPage);
|
||||
58
src/components/forget-password/mobile-form.js
Normal file
58
src/components/forget-password/mobile-form.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Segment from '../shared/segment';
|
||||
import Divider from '../shared/divider';
|
||||
import Alert from '../shared/alert';
|
||||
import Button from '../shared/buttons/button';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import { mobile, required } from '../shared/forms/validations';
|
||||
|
||||
function MobileForm(props) {
|
||||
async function submit({ mobile }, { setErrors }) {
|
||||
try {
|
||||
await props.onSubmit(mobile);
|
||||
} catch (error) {
|
||||
setErrors({ _error: error.data.detail || error.data });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Divider horizontal content='فرم فراموشی رمز عبور' />
|
||||
<Formik initialValues={{ mobile: '' }} onSubmit={submit}>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<Fragment>
|
||||
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
|
||||
<Form>
|
||||
<Field
|
||||
required
|
||||
name='mobile'
|
||||
label='شماره تلفن همراه'
|
||||
type='tel'
|
||||
placeholder='09121234567'
|
||||
component={FormInput}
|
||||
validate={[required, mobile]}
|
||||
/>
|
||||
<div className='text-medium my-4'>
|
||||
شماره تلفن همراه وارد شده هنگام ثبت نام را وارد کنید.
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || isSubmitting}
|
||||
content='ادامه'
|
||||
className='my-2 mr-auto'
|
||||
/>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
MobileForm.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default MobileForm;
|
||||
79
src/components/forget-password/password-form.js
Normal file
79
src/components/forget-password/password-form.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, Fragment } from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import Divider from '../shared/divider';
|
||||
import Alert from '../shared/alert';
|
||||
import Button from '../shared/buttons/button';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import { password, required } from '../shared/forms/validations';
|
||||
|
||||
class PasswordForm extends Component {
|
||||
submit = async ({ password }, { setErrors }) => {
|
||||
try {
|
||||
await this.props.onSubmit(password);
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || 'تغییر رمز عبور انجام نشد.';
|
||||
setErrors({ _error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
validate = values => {
|
||||
const errors = {};
|
||||
if (values.password !== values.password2) errors.password2 = 'رمزهای عبور غیر یکسان هستند.';
|
||||
return errors;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Segment>
|
||||
<Divider horizontal content='فرم تغییر رمز عبور' className='mb-5' />
|
||||
|
||||
<Formik
|
||||
initialValues={{ password: '', password2: '' }}
|
||||
onSubmit={this.submit}
|
||||
validate={this.validate}
|
||||
>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<Fragment>
|
||||
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
|
||||
|
||||
<Form>
|
||||
<Field
|
||||
required
|
||||
name='password'
|
||||
type='password'
|
||||
label='رمز عبور جدید'
|
||||
component={FormInput}
|
||||
validate={[required, password]}
|
||||
className='my-4'
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='password2'
|
||||
type='password'
|
||||
label='تکرار رمز عبور جدید'
|
||||
placeholder='رمز عبور جدید خود را مجدد وارد نمایید'
|
||||
component={FormInput}
|
||||
validate={[required, password]}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || isSubmitting}
|
||||
content='تغییر رمز عبور'
|
||||
className='my-2 mr-auto'
|
||||
/>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PasswordForm.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PasswordForm;
|
||||
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;
|
||||
12
src/components/home/index.js
Normal file
12
src/components/home/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import Home from './home';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<MainLayout loginRequired={false} consultPackageRequired={false} packageRequired={false}>
|
||||
<Home />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
18
src/components/last-review/answer.js
Normal file
18
src/components/last-review/answer.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function Answer(props) {
|
||||
return (
|
||||
<Segment id='answer' color={props.color} colorWeight={300} borderWeight={2}>
|
||||
<span className='font-semibold'>جواب:</span>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Answer.propTypes = {
|
||||
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Answer;
|
||||
37
src/components/last-review/card.js
Normal file
37
src/components/last-review/card.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import OptionContainer from './option-container';
|
||||
import Question from './question';
|
||||
import Answer from './answer';
|
||||
import Reviews from './reviews';
|
||||
import Footer from './footer';
|
||||
import { CORRECT_COLOR, DEFAULT_COLOR } from './utils';
|
||||
|
||||
function Card(props) {
|
||||
const { card, color } = props;
|
||||
|
||||
return (
|
||||
<div className='mb-20'>
|
||||
<Question question={card.question} color={color} />
|
||||
<OptionContainer
|
||||
cardId={card.id}
|
||||
options={card.options}
|
||||
selectedOptionId={props.selectedOptionId}
|
||||
/>
|
||||
{color !== DEFAULT_COLOR && <Reviews color={color} />}
|
||||
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
|
||||
<Footer disabledNext={color === DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Card.defaultProps = {
|
||||
color: DEFAULT_COLOR
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
card: PropTypes.object.isRequired,
|
||||
selectedOptionId: PropTypes.number.isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Card;
|
||||
32
src/components/last-review/container.js
Normal file
32
src/components/last-review/container.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Card from './card';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchLastReview } from '../../redux/actions/review';
|
||||
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
|
||||
|
||||
function LastReviewContainer() {
|
||||
const { chapterId } = useParams();
|
||||
return (
|
||||
<Fetcher
|
||||
action={fetchLastReview.bind(null, chapterId)}
|
||||
stateSelector={state => state.lastReview}
|
||||
>
|
||||
{review => {
|
||||
const color = review.option
|
||||
? review.status === 1
|
||||
? CORRECT_COLOR
|
||||
: WRONG_COLOR
|
||||
: DONT_KNOW_COLOR;
|
||||
return (
|
||||
<Card
|
||||
card={review.studentCard.card}
|
||||
selectedOptionId={review.option || 0}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default LastReviewContainer;
|
||||
58
src/components/last-review/footer.js
Normal file
58
src/components/last-review/footer.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { archiveStudentCard } from '../../redux/actions/student-card';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function Footer(props) {
|
||||
const history = useHistory();
|
||||
const { chapterId, type } = useParams();
|
||||
let nextURL = `/cards/ch-${chapterId}/${type}`;
|
||||
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
|
||||
if (urlSearchParamsSTR) {
|
||||
nextURL += `?${urlSearchParamsSTR}`;
|
||||
}
|
||||
const { disabledNext } = props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={
|
||||
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
|
||||
}
|
||||
>
|
||||
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
|
||||
بازگشت
|
||||
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
|
||||
</Button>
|
||||
<NavLink
|
||||
to={nextURL}
|
||||
color='green'
|
||||
textColor='white'
|
||||
disabled={disabledNext}
|
||||
className='m-2'
|
||||
>
|
||||
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
|
||||
بعدی
|
||||
</NavLink>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Footer.propTypes = {
|
||||
disabledNext: PropTypes.bool.isRequired,
|
||||
showArchive: PropTypes.bool.isRequired,
|
||||
archiveStudentCard: PropTypes.func.isRequired,
|
||||
isPermanentArchive: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps({ review }) {
|
||||
return { isPermanentArchive: !!review.data.studentCard && !!review.data.studentCard.status };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { archiveStudentCard })(Footer);
|
||||
12
src/components/last-review/index.js
Normal file
12
src/components/last-review/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import LastReviewContainer from './container';
|
||||
|
||||
function LastReviewPage() {
|
||||
return (
|
||||
<MainLayout withFooter>
|
||||
<LastReviewContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default LastReviewPage;
|
||||
29
src/components/last-review/option-container.js
Normal file
29
src/components/last-review/option-container.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Option from './option';
|
||||
|
||||
class OptionContainer extends Component {
|
||||
render() {
|
||||
const options = [...this.props.options, { id: 0, text: 'نمی دانم', isCorrect: false }];
|
||||
return (
|
||||
<div id='options' className='space-y-3 px-4'>
|
||||
{options.map((option, index) => (
|
||||
<Option
|
||||
key={option.id}
|
||||
option={option}
|
||||
index={index}
|
||||
isSelectedOption={this.props.selectedOptionId === option.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OptionContainer.propTypes = {
|
||||
cardId: PropTypes.number.isRequired,
|
||||
options: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedOptionId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default OptionContainer;
|
||||
75
src/components/last-review/option.js
Normal file
75
src/components/last-review/option.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/solid';
|
||||
import Button from '../shared/buttons/button';
|
||||
|
||||
function Option(props) {
|
||||
const { option, isSelectedOption } = props;
|
||||
|
||||
const color = option.isCorrect
|
||||
? 'green'
|
||||
: isSelectedOption && option.id
|
||||
? 'red'
|
||||
: option.id
|
||||
? 'gray'
|
||||
: 'yellow';
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
id={`option ${option.id}`}
|
||||
color={color}
|
||||
colorWeight={300}
|
||||
borderWeight={2}
|
||||
textColor='gray'
|
||||
textColorWeight='600'
|
||||
size='xl'
|
||||
justifyContent={option.id ? 'start' : 'between'}
|
||||
className={`w-full border-2 border-${color}-300}`}
|
||||
>
|
||||
<span className={classNames('flex', { 'mx-auto': !option.id })}>
|
||||
{option.id !== 0 && (
|
||||
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
|
||||
)}
|
||||
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
|
||||
{option.text}
|
||||
</span>
|
||||
</span>
|
||||
{isSelectedOption &&
|
||||
(!option.id ? (
|
||||
<ExclamationIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`shrink-0 stroke-current text-yellow-600`}
|
||||
/>
|
||||
) : option.isCorrect ? (
|
||||
<CheckIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`shrink-0 rounded-lg
|
||||
border-2 border-green-500
|
||||
stroke-current text-green-500 ms-auto`}
|
||||
/>
|
||||
) : (
|
||||
<XIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={classNames(
|
||||
`shrink-0 rounded-lg
|
||||
border-2 border-red-500
|
||||
stroke-current text-red-500 ms-auto`
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
Option.propTypes = {
|
||||
option: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isSelectedOption: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Option;
|
||||
21
src/components/last-review/question.js
Normal file
21
src/components/last-review/question.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import Segment from '../shared/segment';
|
||||
import { DEFAULT_COLOR } from './utils';
|
||||
|
||||
function Question(props) {
|
||||
return (
|
||||
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
|
||||
<span className='font-semibold'>سوال:</span>
|
||||
<div dangerouslySetInnerHTML={{ __html: props.question }} />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Question.defaultProps = { color: DEFAULT_COLOR };
|
||||
|
||||
Question.propTypes = {
|
||||
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Question;
|
||||
69
src/components/last-review/reviews.js
Normal file
69
src/components/last-review/reviews.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/solid';
|
||||
import Segment from '../shared/segment';
|
||||
|
||||
function Reviews(props) {
|
||||
const { color, studentCard } = props;
|
||||
return (
|
||||
<Segment id='history' color={color} colorWeight={300} borderWeight={2}>
|
||||
<div className='flex flex-wrap justify-around gap-4'>
|
||||
<p>
|
||||
<span className='font-medium'>شناسه فلش کارت: </span>
|
||||
{studentCard.card.id}
|
||||
</p>
|
||||
<p>
|
||||
<span className='font-medium'>مرور شماره: </span>
|
||||
{studentCard.reviewCount}
|
||||
</p>
|
||||
<div className='flex flex-wrap justify-center gap-x-2 gap-y-4'>
|
||||
<span className='font-medium'>نتایج: </span>
|
||||
<div>
|
||||
{studentCard.reviews.map(review => (
|
||||
<span key={review.id} className='px-px'>
|
||||
{!review.status ? (
|
||||
<ExclamationIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`inline-flex shrink-0 stroke-current text-yellow-500`}
|
||||
/>
|
||||
) : review.status === 1 ? (
|
||||
<CheckIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={`inline-flex shrink-0 rounded-lg
|
||||
border-2 border-green-500
|
||||
stroke-current text-green-500 ms-auto`}
|
||||
/>
|
||||
) : (
|
||||
<XIcon
|
||||
width={25}
|
||||
height={25}
|
||||
className={classNames(
|
||||
`inline-flex shrink-0 rounded-lg
|
||||
border-2 border-red-500
|
||||
stroke-current text-red-500 ms-auto`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
Reviews.propTypes = {
|
||||
studentCard: PropTypes.object.isRequired,
|
||||
color: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps({ lastReview }) {
|
||||
return { studentCard: lastReview.data.studentCard };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Reviews);
|
||||
4
src/components/last-review/utils.js
Normal file
4
src/components/last-review/utils.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const DEFAULT_COLOR = 'gray';
|
||||
export const CORRECT_COLOR = 'green';
|
||||
export const WRONG_COLOR = 'red';
|
||||
export const DONT_KNOW_COLOR = 'yellow';
|
||||
76
src/components/layouts/main/auth-header.js
Normal file
76
src/components/layouts/main/auth-header.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { logoutUser } from '../../../redux/actions/auth';
|
||||
import NavLink from '../../shared/buttons/nav-link';
|
||||
import { fetchStudent } from '../../../redux/actions/student';
|
||||
import ButtonGroup from '../../shared/buttons/button-group';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { fetchUserData } from '../../../redux/actions/user';
|
||||
import { isAnonymous, isAuthenticated as isAuthenticatedFn } from '../../../redux/utils/auth';
|
||||
|
||||
function AuthHeader(props) {
|
||||
useEffect(() => props.fetchStudent(), []);
|
||||
const history = useHistory();
|
||||
const nextURL = `${history.location.pathname}${history.location.search.replace('&', ';')}`;
|
||||
|
||||
if (isAnonymous(props.authStatus)) {
|
||||
return (
|
||||
<ButtonGroup className={props.className}>
|
||||
<NavLink
|
||||
to={`/login?next=${nextURL}`}
|
||||
color='blue'
|
||||
content='ورود'
|
||||
className={props.className}
|
||||
/>
|
||||
<NavLink
|
||||
to={`/register?next=${nextURL}`}
|
||||
color='blue'
|
||||
content='ثبت نام'
|
||||
className={props.className}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthenticated = isAuthenticatedFn(props.authStatus);
|
||||
|
||||
return (
|
||||
<ButtonGroup className={props.className}>
|
||||
<NavLink
|
||||
to='/profile'
|
||||
content={props.name}
|
||||
loading={!isAuthenticated || !props.name}
|
||||
disabled={!isAuthenticated || !props.name}
|
||||
/>
|
||||
<Button
|
||||
content='خروج'
|
||||
color='red'
|
||||
onClick={props.logoutUser.bind(null, history)}
|
||||
loading={!isAuthenticated}
|
||||
disabled={!isAuthenticated}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
AuthHeader.propTypes = {
|
||||
authStatus: PropTypes.string.isRequired,
|
||||
name: PropTypes.string,
|
||||
mobile: PropTypes.string,
|
||||
fetchUserData: PropTypes.func.isRequired,
|
||||
fetchStudent: PropTypes.func.isRequired,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
authStatus: state.auth.status,
|
||||
name: state.user.data.name,
|
||||
mobile: state.user.data.mobile
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { fetchUserData, fetchStudent, logoutUser })(AuthHeader);
|
||||
148
src/components/layouts/main/create-student-form.js
Normal file
148
src/components/layouts/main/create-student-form.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Component, Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Segment from '../../shared/segment';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import Alert from '../../shared/alert';
|
||||
import FormInput from '../../shared/forms/input';
|
||||
import FormSelect from '../../shared/forms/select';
|
||||
import FormCheckbox from '../../shared/forms/checkbox';
|
||||
import {
|
||||
introducerCode,
|
||||
required,
|
||||
requiredTermsAndConditions
|
||||
} from '../../shared/forms/validations';
|
||||
import { fetchIntroducerCode } from '../../../redux/actions/introducer_code';
|
||||
import { createStudent } from '../../../redux/actions/student';
|
||||
import Loader from '../../shared/loader';
|
||||
import { fetchActiveStudentTypes } from '../../../redux/actions/student-type';
|
||||
|
||||
class CreateStudentForm extends Component {
|
||||
componentDidMount() {
|
||||
this.props.fetchActiveStudentTypes();
|
||||
}
|
||||
|
||||
submit = async (values, { setErrors }) => {
|
||||
const { termsAndConditions, introducerCodeText, ...validatedValues } = values;
|
||||
|
||||
let introducerCode = null;
|
||||
if (introducerCodeText) {
|
||||
try {
|
||||
const { payload } = await this.props.fetchIntroducerCode(introducerCodeText);
|
||||
introducerCode = payload.id;
|
||||
} catch (error) {
|
||||
setErrors({ introducerCodeText: error.data.detail || error.data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.props.createStudent({ ...validatedValues, introducerCode });
|
||||
} catch (error) {
|
||||
setErrors(error.data.detail || error.data);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.studentTypes.length === 0) return <Loader />;
|
||||
|
||||
const STUDENT_TYPE_OPTIONS = this.props.studentTypes.map(({ id, name }) => ({
|
||||
name,
|
||||
value: id,
|
||||
text: name
|
||||
}));
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Formik
|
||||
initialValues={{
|
||||
type: '',
|
||||
notify: true,
|
||||
termsAndConditions: false,
|
||||
introducerCodeText: ''
|
||||
}}
|
||||
onSubmit={this.submit}
|
||||
>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<Fragment>
|
||||
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
|
||||
<Form noValidate className='flex flex-col justify-center gap-6'>
|
||||
<Field
|
||||
required
|
||||
name='type'
|
||||
label='وضعیت تحصیلی'
|
||||
component={FormSelect}
|
||||
options={STUDENT_TYPE_OPTIONS}
|
||||
validate={required}
|
||||
className='mx-auto w-full sm:w-2/3 lg:w-1/2 xl:w-1/3'
|
||||
/>
|
||||
<Field
|
||||
name='introducerCodeText'
|
||||
label='کد معرف'
|
||||
component={FormInput}
|
||||
maxLength={6}
|
||||
validate={introducerCode}
|
||||
autoComplete='off'
|
||||
className='mx-auto my-6 w-full sm:max-w-[240px]'
|
||||
/>
|
||||
<Field
|
||||
name='notify'
|
||||
label={
|
||||
<Fragment>
|
||||
مایل به دریافت
|
||||
<span className='font-medium'> پیامک های اطلاع رسانی </span>
|
||||
سامانه فلش کارت های مدمشاور شامل
|
||||
<span className='font-medium'> پکیج های جدید </span>و
|
||||
<span className='font-medium'> کدهای تخفیف </span>
|
||||
هستم.
|
||||
</Fragment>
|
||||
}
|
||||
component={FormCheckbox}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='termsAndConditions'
|
||||
label={
|
||||
<Fragment>
|
||||
<Link to='/terms-and-conditions' className='text-indigo-600'>
|
||||
شرایط و قوانین{' '}
|
||||
</Link>
|
||||
<span>را مطالعه کردهام و آنها را میپذیرم.</span>
|
||||
</Fragment>
|
||||
}
|
||||
component={FormCheckbox}
|
||||
validate={requiredTermsAndConditions}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || isSubmitting}
|
||||
content='تایید'
|
||||
className='col-span-3 my-4 mr-auto ml-2'
|
||||
/>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CreateStudentForm.propTypes = {
|
||||
studentTypes: PropTypes.array.isRequired,
|
||||
createStudent: PropTypes.func.isRequired,
|
||||
fetchActiveStudentTypes: PropTypes.func.isRequired,
|
||||
fetchIntroducerCode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return { studentTypes: state.studentTypes.data };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
createStudent,
|
||||
fetchActiveStudentTypes,
|
||||
fetchIntroducerCode
|
||||
})(CreateStudentForm);
|
||||
14
src/components/layouts/main/dark-button.js
Normal file
14
src/components/layouts/main/dark-button.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { toggleTheme } from '../../../redux/actions/theme';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function ThemeButton(props) {
|
||||
return <Button color='blue' content='دارک' onClick={props.toggleTheme} />;
|
||||
}
|
||||
|
||||
ThemeButton.propTypes = {
|
||||
toggleTheme: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { toggleTheme })(ThemeButton);
|
||||
37
src/components/layouts/main/footer.js
Normal file
37
src/components/layouts/main/footer.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
function Footer() {
|
||||
const location = useLocation();
|
||||
if (location.pathname !== '/') return <footer data-testid='footer' />;
|
||||
|
||||
const eNamadId = '486556';
|
||||
const eNamadCode = 'KOfWnHX6MPAGXu9vMNY9i2avACZHFtyQ';
|
||||
|
||||
/* eslint-disable react/jsx-no-target-blank */
|
||||
return (
|
||||
<footer data-testid='footer' className='z-50 w-screen'>
|
||||
<div className='flex justify-end'>
|
||||
<div id='samandehi' className='m-3 h-[75px] w-[75px]'></div>
|
||||
<div id='e-namad' className=' m-3 h-[75px] w-[75px]'>
|
||||
<a
|
||||
referrerPolicy='origin'
|
||||
target='_blank'
|
||||
href={`https://trustseal.enamad.ir/?id=${eNamadId}&Code=${eNamadCode}`}
|
||||
>
|
||||
<img
|
||||
id={eNamadCode}
|
||||
referrerPolicy='origin'
|
||||
src={`https://trustseal.enamad.ir/logo.aspx?id=${eNamadId}&Code=${eNamadCode}`}
|
||||
alt='e-namad'
|
||||
width='75px'
|
||||
height='75px'
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
90
src/components/layouts/main/header.js
Normal file
90
src/components/layouts/main/header.js
Normal file
@@ -0,0 +1,90 @@
|
||||
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';
|
||||
import Button from '../../shared/buttons/button';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../../utils/index';
|
||||
|
||||
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' wrapperClassName='hidden md:block'>
|
||||
<span className='text-lg font-bold'>Med Moshaver</span>
|
||||
</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 py-1 md:inline-block md:border-none'
|
||||
>
|
||||
<NavLink to={link.href} textSize='base' color='transparent' className='w-100 mx-auto'>
|
||||
{link.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
))}
|
||||
<div className='w-100 block rounded-none border-t-2 border-blue-500 py-1 md:inline-block md:border-none'>
|
||||
<a href={process.env.REACT_APP_INTRODUCER_BASE_URL} target='_blank' rel='noreferrer'>
|
||||
<Button
|
||||
textSize='base'
|
||||
color='transparent'
|
||||
className='w-100 mx-auto'
|
||||
content='معرفی به دوستان'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ id: 1, name: 'خانه', href: '/' },
|
||||
{ id: 2, name: 'بستهها', href: DEFAULT_DECKS_ROUTE },
|
||||
{ id: 3, name: 'فروشگاه', href: '/store' },
|
||||
{ id: 4, name: 'رتبه بندی', href: '/ranking' },
|
||||
{ id: 5, name: 'نمودار', href: '/charts/weekly' }
|
||||
];
|
||||
|
||||
export default Header;
|
||||
102
src/components/layouts/main/index.js
Normal file
102
src/components/layouts/main/index.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Fragment, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect, useLocation } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Header from './header';
|
||||
import Footer from './footer';
|
||||
import Loader from '../../shared/loader';
|
||||
import ScrollToTop from '../../shared/buttons/scroll-to-top';
|
||||
import ConfirmModal from '../../shared/confirm-modal';
|
||||
import { updateStudentNotify } from '../../../redux/actions/student';
|
||||
import CreateStudentForm from './create-student-form';
|
||||
import { isAnonymous, isExactAuthenticated, isLoading, isStudent } from '../../../redux/utils/auth';
|
||||
|
||||
function MainLayout(props) {
|
||||
const { title, description, loginRequired } = props;
|
||||
|
||||
const location = useLocation();
|
||||
const currentURL = `${location.pathname}${location.search.replace('&', ';')}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (title) document.title = title;
|
||||
if (description) document.description = description;
|
||||
}, [title, description]);
|
||||
|
||||
if (loginRequired && isAnonymous(props.authStatus)) {
|
||||
return <Redirect to={`/login?next=${currentURL}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex h-screen flex-col'>
|
||||
<Header />
|
||||
<main className='container relative m-auto flex-1 py-4'>{renderContent(props)}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<ScrollToTop withFooter={props.withFooter} />
|
||||
<ConfirmModal
|
||||
showModal={props.notify === null}
|
||||
header='اطلاع رسانی پیامکی سامانه فلش کارت های مدمشاور'
|
||||
content={
|
||||
<div>
|
||||
<p>
|
||||
با توجه به فعال شدن امکان اطلاع رسانی پیامکی سامانه فلش کارتهای مدمشاور، از این پس
|
||||
فعال شدن پکیجهای جدید و کدهای تخفیف، از طریق پیامک اطلاع رسانی خواهد شد.
|
||||
</p>
|
||||
<p>
|
||||
آیا مایل به دریافت
|
||||
<span className='font-medium'> پیامک های اطلاع رسانی </span>
|
||||
سامانه فلش کارتهای مدمشاور هستید؟
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onConfirm={props.updateStudentNotify.bind(null, true)}
|
||||
onCancel={props.updateStudentNotify.bind(null, false)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent(props) {
|
||||
const { authStatus, loginRequired } = props;
|
||||
if (loginRequired && isLoading(authStatus)) {
|
||||
return (
|
||||
<div>
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loginRequired && isExactAuthenticated(authStatus)) {
|
||||
return (
|
||||
<div className='mx-auto w-full w-full sm:w-3/4 lg:w-2/3 2xl:w-1/2'>
|
||||
<CreateStudentForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!loginRequired || isStudent(authStatus)) return props.children;
|
||||
}
|
||||
|
||||
MainLayout.defaultProps = {
|
||||
loginRequired: true,
|
||||
withFooter: false
|
||||
};
|
||||
|
||||
MainLayout.propTypes = {
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
loginRequired: PropTypes.bool.isRequired,
|
||||
authStatus: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
withFooter: PropTypes.bool.isRequired,
|
||||
notify: PropTypes.bool,
|
||||
updateStudentNotify: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
authStatus: state.auth.status,
|
||||
notify: state.student.data?.notify
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { updateStudentNotify })(MainLayout);
|
||||
68
src/components/login/form.js
Normal file
68
src/components/login/form.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Segment from '../shared/segment';
|
||||
import { mobile, required } from '../shared/forms/validations';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { loginUser } from '../../redux/actions/auth';
|
||||
import Alert from '../shared/alert';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import ButtonGroup from '../shared/buttons/button-group';
|
||||
|
||||
function LoginForm(props) {
|
||||
async function submit({ mobile, password }, { setErrors }) {
|
||||
try {
|
||||
await props.loginUser(mobile, password);
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || 'شماره تلفن یا رمز عبور نادرست است.';
|
||||
setErrors({ _error: errorMessage });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Formik initialValues={{ mobile: '', password: '' }} onSubmit={submit}>
|
||||
{({ 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
|
||||
id='username'
|
||||
name='mobile'
|
||||
label='شماره تلفن همراه'
|
||||
type='tel'
|
||||
placeholder='09121234567'
|
||||
component={FormInput}
|
||||
validate={mobile}
|
||||
autoComplete='username'
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='password'
|
||||
label='رمز عبور'
|
||||
type='password'
|
||||
component={FormInput}
|
||||
validate={required}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
<ButtonGroup float>
|
||||
<Button type='submit' color='green' content='ورود' />
|
||||
<NavLink to='/forget-password' color='indigo' content='بازیابی رمز عبور' />
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
LoginForm.propTypes = {
|
||||
loginUser: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { loginUser })(LoginForm);
|
||||
28
src/components/login/index.js
Normal file
28
src/components/login/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import MainLayout from '../layouts/main';
|
||||
import LoginForm from './form';
|
||||
import useQueryParams from '../../hooks/query-params';
|
||||
import { useIsAuthenticated } from '../../redux/hooks/auth';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function LoginPage() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { next } = useQueryParams();
|
||||
|
||||
if (isAuthenticated) {
|
||||
const nextURL = next ? next.replace(';', '&') : DEFAULT_DECKS_ROUTE;
|
||||
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;
|
||||
67
src/components/order/code.js
Normal file
67
src/components/order/code.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Button from '../shared/buttons/button';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import { required } from '../shared/forms/validations';
|
||||
import { fetchCode } from '../../redux/actions/code';
|
||||
import { updateOrderCode } from '../../redux/actions/order';
|
||||
|
||||
function Code(props) {
|
||||
return (
|
||||
<div className='my-4'>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={{ codeText: props.code ? props.code.text : '' }}
|
||||
onSubmit={async ({ codeText }, { setErrors }) => {
|
||||
const defaultErrorMessage = props.code
|
||||
? 'حذف کد تخفیف انجام نشد.'
|
||||
: 'کد تخفیف وارد شده معتبر نمیباشد.';
|
||||
try {
|
||||
if (props.code) {
|
||||
await props.updateOrderCode(props.orderId, null);
|
||||
} else {
|
||||
await props.updateOrderCode(props.orderId, codeText);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrors({ _error: error.message || defaultErrorMessage });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<Fragment>
|
||||
<Form>
|
||||
<Field
|
||||
required
|
||||
inline
|
||||
disabled={!!props.code}
|
||||
name='codeText'
|
||||
label='کد تخفیف'
|
||||
component={FormInput}
|
||||
validate={[required]}
|
||||
/>
|
||||
<Button
|
||||
color={props.code ? 'red' : 'blue'}
|
||||
inline
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
content={props.code ? 'حذف کد تخفیف' : 'اعمال کد تخفیف'}
|
||||
/>
|
||||
{dirty && errors._error && <p className='mt-3 mb-5 text-red-500'>{errors._error}</p>}
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Code.propTypes = {
|
||||
orderId: PropTypes.number.isRequired,
|
||||
code: PropTypes.object,
|
||||
fetchCode: PropTypes.func.isRequired,
|
||||
updateOrderCode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { fetchCode, updateOrderCode })(Code);
|
||||
16
src/components/order/container.js
Normal file
16
src/components/order/container.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Order from './order';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchOrder } from '../../redux/actions/order';
|
||||
|
||||
function OrderContainer() {
|
||||
const { orderId } = useParams();
|
||||
|
||||
return (
|
||||
<Fetcher action={fetchOrder.bind(null, orderId)} stateSelector={state => state.order}>
|
||||
{order => <Order order={order} />}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderContainer;
|
||||
12
src/components/order/index.js
Normal file
12
src/components/order/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import OrderContainer from './container';
|
||||
|
||||
function OrderPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<OrderContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderPage;
|
||||
90
src/components/order/order-items-table.js
Normal file
90
src/components/order/order-items-table.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function OrderItemsTable(props) {
|
||||
const { orderItems, code, totalPrice, discountPrice, paymentPrice, creditPrice } = props;
|
||||
return (
|
||||
<div className='my-4'>
|
||||
<p className='font-medium'>بستههای انتخاب شده:</p>
|
||||
<table className='my-4 w-full border-collapse border border-green-600 lg:w-1/2'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='border border-green-400 py-1.5'>#</th>
|
||||
<th className='border border-green-400 py-1.5'>نام بسته</th>
|
||||
<th className='border border-green-400 py-1.5'>قیمت بسته</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orderItems
|
||||
.sort((d1, d2) => d2.item.weight - d1.item.weight)
|
||||
.map((deck, index) => (
|
||||
<tr key={deck.id}>
|
||||
<td className='border border-green-400 py-1.5 text-center'>{index + 1}</td>
|
||||
<td className='border border-green-400 py-1.5 text-center'>{deck.name}</td>
|
||||
<td className='border border-green-400 py-1.5 text-center'>
|
||||
<span>{deck.price ? `${deck.price.toLocaleString()} ریال` : 'رایگان'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium' colSpan={2}>
|
||||
مجموع
|
||||
</td>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium'>
|
||||
{totalPrice ? `${totalPrice.toLocaleString()} ریال` : 'رایگان'}
|
||||
</td>
|
||||
</tr>
|
||||
{discountPrice !== totalPrice && (
|
||||
<tr>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium' colSpan={2}>
|
||||
مجموع پس از اعمال تخفیف معرف
|
||||
</td>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium'>
|
||||
{`${discountPrice.toLocaleString()} ریال`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!!code && (
|
||||
<tr>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium' colSpan={2}>
|
||||
<span>مجموع پس از اعمال کد تخفیف </span>
|
||||
<span>{` (${code.amount}%) `}</span>
|
||||
<span>{code.text === ALL_IN_ONE_CODE_TEXT ? 'خرید همه بستهها' : code.text}</span>
|
||||
</td>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium'>
|
||||
{`${paymentPrice.toLocaleString()} ریال`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{props.creditPrice !== 0 && (
|
||||
<tr>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium' colSpan={2}>
|
||||
<span>مجموع پس از کسر مبلغ </span>
|
||||
<span>{`${creditPrice.toLocaleString()} ریال`}</span>
|
||||
<span> از اعتبار شما</span>
|
||||
</td>
|
||||
<td className='border border-green-400 py-1.5 text-center font-medium'>
|
||||
{`${(props.paymentPrice - props.creditPrice).toLocaleString()} ریال`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ALL_IN_ONE_CODE_TEXT = '_AllInOne';
|
||||
|
||||
OrderItemsTable.propTypes = {
|
||||
orderItems: PropTypes.array.isRequired,
|
||||
code: PropTypes.object,
|
||||
totalPrice: PropTypes.number.isRequired,
|
||||
paymentPrice: PropTypes.number.isRequired,
|
||||
discountPrice: PropTypes.number.isRequired,
|
||||
creditPrice: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default OrderItemsTable;
|
||||
131
src/components/order/order.js
Normal file
131
src/components/order/order.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Component } from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { createPayment } from '../../redux/actions/payment';
|
||||
import OrderItemsTable from './order-items-table';
|
||||
import Code from './code';
|
||||
import Segment from '../shared/segment';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { payOrderFromCredit } from '../../redux/actions/order';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
class Order extends Component {
|
||||
handleConfirm = async () => {
|
||||
const { order } = this.props;
|
||||
const {
|
||||
payload: { url }
|
||||
} = await this.props.createPayment(order.id);
|
||||
window.location.assign(url);
|
||||
};
|
||||
|
||||
handlePayFromCredit = () => {
|
||||
const { order } = this.props;
|
||||
this.props.payOrderFromCredit(order.id);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { order } = this.props;
|
||||
|
||||
if (order.status) {
|
||||
return (
|
||||
<Segment textAlign='center'>
|
||||
<p>
|
||||
<span>بستههای انتخاب شده </span>
|
||||
<span className='font-medium'>خریداری شده است</span>
|
||||
<span>، با رفتن به صفحه </span>
|
||||
<a>
|
||||
<Link to={DEFAULT_DECKS_ROUTE}>
|
||||
<span className='font-medium text-blue-900'>بستهها</span>
|
||||
</Link>
|
||||
</a>
|
||||
<span> می توانید از آنها استفاه نمایید.</span>
|
||||
</p>
|
||||
<NavLink
|
||||
to={DEFAULT_DECKS_ROUTE}
|
||||
color='blue'
|
||||
content='بسته ها'
|
||||
className='my-4 mx-auto'
|
||||
/>
|
||||
<NavLink to='/store' color='green' content='بازگشت به فروشگاه' className='my-4 mx-auto' />
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='mx-6'>
|
||||
<OrderItemsTable
|
||||
orderItems={order.orderItems}
|
||||
code={order.code}
|
||||
totalPrice={order.price}
|
||||
paymentPrice={order.paymentPrice}
|
||||
discountPrice={order.discountPrice}
|
||||
creditPrice={order.creditPrice}
|
||||
/>
|
||||
<Code orderId={order.id} code={order.code} />
|
||||
{order.paymentPrice === order.creditPrice ? (
|
||||
<>
|
||||
<p>
|
||||
<span>با فشردن دکمه </span>
|
||||
<span className='font-medium'>سبز رنگ</span>
|
||||
<span> زیر با پرداخت مبلغ </span>
|
||||
<span className='font-bold'>{order.creditPrice.toLocaleString()} ریال</span>
|
||||
<span> از اعتبار خود میتوانید از بسته های انتخاب شده استفاده کنید.</span>
|
||||
</p>
|
||||
<Button
|
||||
color='green'
|
||||
colorWeight={400}
|
||||
content='پرداخت از اعتبار'
|
||||
onClick={this.handlePayFromCredit}
|
||||
className='my-2 mx-1'
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<span>با فشردن دکمه </span>
|
||||
<span className='font-medium'>سبز رنگ</span>
|
||||
<span> زیر به درگاه پرداخت متصل میشوید و با افزایش اعتبار حساب خود به مبلغ </span>
|
||||
<span className='font-bold'>
|
||||
{(order.paymentPrice - order.creditPrice).toLocaleString()} ریال
|
||||
</span>
|
||||
<span> ، پس از برگشت به سایت میتوانید از بسته های انتخاب شده استفاده کنید.</span>
|
||||
</p>
|
||||
<Button
|
||||
color='green'
|
||||
colorWeight={400}
|
||||
content='اتصال به درگاه پرداخت'
|
||||
onClick={this.handleConfirm}
|
||||
className='my-2 mx-1'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<span>و با فشردن دکمه </span>
|
||||
<span className='font-medium'>قرمز رنگ</span>
|
||||
<span>
|
||||
{' '}
|
||||
زیر میتوانید به صفحه فروشگاه بازگشته و بستههای انتخابی خود را ویرایش نمایید.
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
color='red'
|
||||
colorWeight={400}
|
||||
content='بازگشت به فروشگاه'
|
||||
onClick={this.props.history.goBack}
|
||||
className='my-2 mx-1'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Order.propTypes = {
|
||||
order: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
createPayment: PropTypes.func.isRequired,
|
||||
payOrderFromCredit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default compose(withRouter, connect(null, { createPayment, payOrderFromCredit }))(Order);
|
||||
21
src/components/payment-verify/container.js
Normal file
21
src/components/payment-verify/container.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import PaymentVerify from './payment-verify';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchPayment } from '../../redux/actions/payment';
|
||||
|
||||
function PaymentVerifyContainer({ code }) {
|
||||
const { paymentId } = useParams();
|
||||
|
||||
return (
|
||||
<Fetcher action={fetchPayment.bind(null, paymentId)} stateSelector={state => state.payment}>
|
||||
{payment => <PaymentVerify code={code} payment={payment} />}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
PaymentVerifyContainer.propTypes = {
|
||||
code: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default PaymentVerifyContainer;
|
||||
23
src/components/payment-verify/index.js
Normal file
23
src/components/payment-verify/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import PaymentVerifyContainer from './container';
|
||||
import PaymentFailure from './payment-failure';
|
||||
import Segment from '../shared/segment';
|
||||
import useQueryParams from '../../hooks/query-params';
|
||||
|
||||
function PaymentVerifyPage() {
|
||||
const { status, code, error } = useQueryParams();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Segment>
|
||||
{status === '200' ? (
|
||||
<PaymentVerifyContainer code={code} />
|
||||
) : (
|
||||
<PaymentFailure code={code} error={error} />
|
||||
)}
|
||||
</Segment>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentVerifyPage;
|
||||
33
src/components/payment-verify/payment-failure.js
Normal file
33
src/components/payment-verify/payment-failure.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function PaymentFailure(props) {
|
||||
const { code, error } = props;
|
||||
|
||||
return (
|
||||
<div className='text-center text-xl'>
|
||||
<p>متاسفانه تراکنش ناموفق بود.</p>
|
||||
{code && (
|
||||
<p className='my-1'>
|
||||
<span>کد خطا: </span>
|
||||
<span className='plain-text'>{code}</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className='my-1'>
|
||||
<p>خطا: </p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<NavLink to={DEFAULT_DECKS_ROUTE} color='green' content='بازگشت' className='mx-auto my-6' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PaymentFailure.propTypes = {
|
||||
code: PropTypes.string,
|
||||
error: PropTypes.string
|
||||
};
|
||||
|
||||
export default PaymentFailure;
|
||||
36
src/components/payment-verify/payment-verify.js
Normal file
36
src/components/payment-verify/payment-verify.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function PaymentVerify(props) {
|
||||
const { payment } = props;
|
||||
const paymentCode = payment.result.result;
|
||||
|
||||
return (
|
||||
<div className='text-center text-xl'>
|
||||
{payment.status && [100, 201].includes(paymentCode) ? (
|
||||
paymentCode === 100 ? (
|
||||
<>
|
||||
<p>بستههای موردنظر شما با موفقیت خریداری گردید.</p>
|
||||
<p className='my-1'>
|
||||
<span>شماره مرجع: </span>
|
||||
<span>{payment.result.refNumber}</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>تراکنش با موفقیت انجام شده است.</p>
|
||||
)
|
||||
) : (
|
||||
<p>متاسفانه تراکنش ناموفق بود.</p>
|
||||
)}
|
||||
<NavLink to={DEFAULT_DECKS_ROUTE} color='green' content='بازگشت' className='mx-auto my-6' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PaymentVerify.propTypes = {
|
||||
payment: PropTypes.object.isRequired,
|
||||
code: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default PaymentVerify;
|
||||
13
src/components/profile/container.js
Normal file
13
src/components/profile/container.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Profile from './profile';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchUserData } from '../../redux/actions/user';
|
||||
|
||||
function ProfileContainer() {
|
||||
return (
|
||||
<Fetcher action={fetchUserData} stateSelector={state => state.user}>
|
||||
{user => <Profile user={user} />}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileContainer;
|
||||
12
src/components/profile/index.js
Normal file
12
src/components/profile/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import ProfileContainer from './container';
|
||||
|
||||
function ProfilePage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<ProfileContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
||||
35
src/components/profile/profile.js
Normal file
35
src/components/profile/profile.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function Profile({ user }) {
|
||||
return (
|
||||
<div className='flex flex-col gap-3 caret-transparent sm:flex-row'>
|
||||
<div className='flex basis-1/4 items-center justify-center rounded border p-4'>
|
||||
<a>مشخصات</a>
|
||||
</div>
|
||||
<div className='basis-3/4 rounded border p-4'>
|
||||
<p>
|
||||
<span>نام: </span>
|
||||
<span>{user.firstName}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>نام خانوادگی: </span>
|
||||
<span>{user.lastName}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>موبایل: </span>
|
||||
<span>{user.mobile}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>اعتبار: </span>
|
||||
<span>{`${user.credit.toLocaleString()} ریال`}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Profile.propTypes = {
|
||||
user: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
33
src/components/ranking/container.js
Normal file
33
src/components/ranking/container.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import RankingTab from './tab';
|
||||
import Table from './table';
|
||||
import UpdateNickNameForm from './update-nickname-form';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchStudent } from '../../redux/actions/student';
|
||||
import { fetchRanking } from '../../redux/actions/ranking';
|
||||
|
||||
function RankingContainer() {
|
||||
const { rankType } = useParams();
|
||||
|
||||
return (
|
||||
<Fetcher action={fetchStudent} stateSelector={state => state.student}>
|
||||
{student =>
|
||||
!student.nickName || student.nickName.includes('***') ? (
|
||||
<UpdateNickNameForm />
|
||||
) : (
|
||||
<div className='px-2'>
|
||||
<RankingTab />
|
||||
<Fetcher
|
||||
action={fetchRanking.bind(null, rankType)}
|
||||
stateSelector={state => state.ranking}
|
||||
>
|
||||
{rankingData => <Table rankingData={rankingData} currentStudentId={student.id} />}
|
||||
</Fetcher>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
export default RankingContainer;
|
||||
12
src/components/ranking/index.js
Normal file
12
src/components/ranking/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import RankingContainer from './container';
|
||||
|
||||
function RankingPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<RankingContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default RankingPage;
|
||||
26
src/components/ranking/tab.js
Normal file
26
src/components/ranking/tab.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import ButtonGroup from '../shared/buttons/button-group';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
function RankingTab() {
|
||||
const { rankType } = useParams();
|
||||
|
||||
return (
|
||||
<ButtonGroup className='mx-auto mb-16 w-min'>
|
||||
<NavLink to='/ranking/daily' color={rankType === 'daily' ? 'blue' : 'gray'}>
|
||||
روزانه
|
||||
</NavLink>
|
||||
<NavLink to='/ranking/weekly' color={rankType === 'weekly' ? 'blue' : 'gray'}>
|
||||
هفتگی
|
||||
</NavLink>
|
||||
<NavLink to='/ranking/monthly' color={rankType === 'monthly' ? 'blue' : 'gray'}>
|
||||
ماهانه
|
||||
</NavLink>
|
||||
<NavLink to='/ranking' color={rankType ? 'gray' : 'blue'}>
|
||||
کلی
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default RankingTab;
|
||||
42
src/components/ranking/table.js
Normal file
42
src/components/ranking/table.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
function Table(props) {
|
||||
return (
|
||||
<table className='mx-auto my-4 w-full border-collapse lg:w-1/2'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='border border-gray-300 py-1.5'>رتبه</th>
|
||||
<th className='border border-gray-300 py-1.5'>نام مستعار</th>
|
||||
<th className='border border-gray-300 py-1.5'>امتیاز</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.rankingData.map(({ pk, nickName, rank, score }, index, rankingData) => (
|
||||
<Fragment key={rank}>
|
||||
{index > 4 && rankingData[index - 1].rank !== rank - 1 && (
|
||||
<tr className='bg-gray-50'>
|
||||
<td colSpan={3} className='border border-gray-300 py-1.5 text-center text-xl'>
|
||||
...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className={classNames({ 'bg-green-100': props.currentStudentId === pk })}>
|
||||
<td className='border border-gray-300 py-1.5 text-center'>{rank}</td>
|
||||
<td className='ltr border border-gray-300 py-1.5 text-center'>{nickName}</td>
|
||||
<td className='border border-gray-300 py-1.5 text-center'>{score}</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
Table.propTypes = {
|
||||
rankingData: PropTypes.array.isRequired,
|
||||
currentStudentId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default Table;
|
||||
50
src/components/ranking/update-nickname-form.js
Normal file
50
src/components/ranking/update-nickname-form.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Button from '../shared/buttons/button';
|
||||
import Segment from '../shared/segment';
|
||||
import Alert from '../shared/alert';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import { atLeastNChars } from '../shared/forms/validations';
|
||||
import { updateStudentNickName } from '../../redux/actions/student';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function UpdateNickNameForm(props) {
|
||||
async function submit({ nickName }, { setErrors }) {
|
||||
try {
|
||||
await props.updateStudentNickName(nickName);
|
||||
} catch (e) {
|
||||
setErrors(e.data.detail || e.data);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment className='mx-auto sm:w-2/3 lg:w-1/2'>
|
||||
<p className='text-center'>برای دیدن رتبهها اول باید یک نام مستعار انتخاب کنید:</p>
|
||||
<Formik initialValues={{ nickName: '' }} onSubmit={submit}>
|
||||
{({ 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='nickName'
|
||||
label='نام مستعار'
|
||||
component={FormInput}
|
||||
validate={atLeastNChars(4)}
|
||||
/>
|
||||
<Button type='submit' color='green' content='ثبت' />
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateNickNameForm.propTypes = {
|
||||
updateStudentNickName: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { updateStudentNickName })(UpdateNickNameForm);
|
||||
82
src/components/register/content.js
Normal file
82
src/components/register/content.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import MobileForm from './mobile-form';
|
||||
import RegisterForm from './register-form';
|
||||
import SMSKeyForm from '../shared/sms-key-form';
|
||||
import Alert from '../shared/alert';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchActiveStudentTypes } from '../../redux/actions/student-type';
|
||||
import {
|
||||
checkSmsKeyRequest,
|
||||
createTempUser,
|
||||
sendSmsKeyRequest
|
||||
} from '../../redux/actions/temp-user';
|
||||
|
||||
function RegisterContent(props) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [mobile, setMobile] = useState(null);
|
||||
const [authKey, setAuthKey] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
if (error) return <Alert content={error} />;
|
||||
|
||||
if (step === MOBILE) {
|
||||
return (
|
||||
<div className='w-100 mx-auto md:w-4/5 lg:w-3/5'>
|
||||
<MobileForm
|
||||
onSubmit={async function (mobile) {
|
||||
await props.createTempUser(mobile);
|
||||
setMobile(mobile);
|
||||
setStep(SMS_KEY);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === SMS_KEY) {
|
||||
return (
|
||||
<div className='w-100 mx-auto md:w-4/5 lg:w-3/5'>
|
||||
<SMSKeyForm
|
||||
back
|
||||
mobile={mobile}
|
||||
onSubmit={async smsKey => {
|
||||
const {
|
||||
payload: { authKey }
|
||||
} = await props.checkSmsKeyRequest(mobile, smsKey);
|
||||
setAuthKey(authKey);
|
||||
setStep(REGISTER);
|
||||
}}
|
||||
onResend={async () => await props.sendSmsKeyRequest(mobile)}
|
||||
onBack={() => setStep(MOBILE)}
|
||||
onError={setError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === REGISTER) {
|
||||
return (
|
||||
<Fetcher action={fetchActiveStudentTypes} stateSelector={state => state.studentTypes}>
|
||||
{studentTypes => (
|
||||
<RegisterForm mobile={mobile} authKey={authKey} studentTypes={studentTypes} />
|
||||
)}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const [MOBILE, SMS_KEY, REGISTER] = [0, 1, 2];
|
||||
|
||||
RegisterContent.propTypes = {
|
||||
createTempUser: PropTypes.func.isRequired,
|
||||
sendSmsKeyRequest: PropTypes.func.isRequired,
|
||||
checkSmsKeyRequest: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { createTempUser, sendSmsKeyRequest, checkSmsKeyRequest })(
|
||||
RegisterContent
|
||||
);
|
||||
18
src/components/register/index.js
Normal file
18
src/components/register/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import MainLayout from '../layouts/main';
|
||||
import RegisterContent from './content';
|
||||
import { useIsAuthenticated } from '../../redux/hooks/auth';
|
||||
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
|
||||
|
||||
function RegisterPage() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
if (isAuthenticated) return <Redirect to={DEFAULT_DECKS_ROUTE} />;
|
||||
|
||||
return (
|
||||
<MainLayout loginRequired={false}>
|
||||
<RegisterContent />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
48
src/components/register/mobile-form.js
Normal file
48
src/components/register/mobile-form.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Segment from '../shared/segment';
|
||||
import Alert from '../shared/alert';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import Button from '../shared/buttons/button';
|
||||
import { mobile } from '../shared/forms/validations';
|
||||
|
||||
function MobileForm(props) {
|
||||
return (
|
||||
<Segment>
|
||||
<Formik initialValues={{ mobile: '' }} onSubmit={submit.bind(null, props)}>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<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}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
color='green'
|
||||
content='تایید'
|
||||
disabled={!dirty || isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
async function submit(props, { mobile }, { setErrors }) {
|
||||
try {
|
||||
await props.onSubmit(mobile);
|
||||
} catch (error) {
|
||||
setErrors({ _error: error.data.detail || error.data });
|
||||
}
|
||||
}
|
||||
|
||||
export default MobileForm;
|
||||
206
src/components/register/register-form.js
Normal file
206
src/components/register/register-form.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Component, Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Segment from '../shared/segment';
|
||||
import Button from '../shared/buttons/button';
|
||||
import Alert from '../shared/alert';
|
||||
import FormInput from '../shared/forms/input';
|
||||
import FormSelect from '../shared/forms/select';
|
||||
import FormCheckbox from '../shared/forms/checkbox';
|
||||
import {
|
||||
gender,
|
||||
introducerCode,
|
||||
password,
|
||||
persian,
|
||||
required,
|
||||
requiredTermsAndConditions
|
||||
} from '../shared/forms/validations';
|
||||
// import {normalizeName} from '../shared/forms/normalizers';
|
||||
import { registerUser } from '../../redux/actions/auth';
|
||||
import { fetchIntroducerCode } from '../../redux/actions/introducer_code';
|
||||
|
||||
class RegisterForm extends Component {
|
||||
validate = async values => {
|
||||
const errors = {};
|
||||
if (values.password !== values.rePassword) errors.rePassword = 'رمزهای عبور غیر یکسان هستند.';
|
||||
return errors;
|
||||
};
|
||||
|
||||
submit = async (values, { setErrors }) => {
|
||||
const { rePassword, termsAndConditions, introducerCodeText, ...validatedValues } = values;
|
||||
|
||||
let introducerCode = null;
|
||||
if (introducerCodeText) {
|
||||
try {
|
||||
const { payload } = await this.props.fetchIntroducerCode(introducerCodeText);
|
||||
introducerCode = payload.id;
|
||||
} catch (error) {
|
||||
setErrors({ introducerCodeText: error.data.detail || error.data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.props.registerUser({
|
||||
...validatedValues,
|
||||
introducerCode,
|
||||
mobile: this.props.mobile,
|
||||
authKey: this.props.authKey
|
||||
});
|
||||
} catch (error) {
|
||||
setErrors(error.data.detail || error.data);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const STUDENT_TYPE_OPTIONS = this.props.studentTypes.map(({ id, name }) => ({
|
||||
name,
|
||||
value: id,
|
||||
text: name
|
||||
}));
|
||||
return (
|
||||
<Segment>
|
||||
<Formik
|
||||
initialValues={{
|
||||
mobile: this.props.mobile,
|
||||
authKey: this.props.authKey,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
gender: '',
|
||||
password: '',
|
||||
rePassword: '',
|
||||
type: '',
|
||||
notify: true,
|
||||
termsAndConditions: false,
|
||||
introducerCodeText: ''
|
||||
}}
|
||||
onSubmit={this.submit}
|
||||
validate={this.validate}
|
||||
>
|
||||
{({ errors, dirty, isSubmitting }) => (
|
||||
<Fragment>
|
||||
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
|
||||
<Form noValidate>
|
||||
<input type='hidden' id='username' name='mobile' />
|
||||
<input type='hidden' id='authKey' name='authKey' />
|
||||
<div className='grid grid-cols-2 gap-5 p-3'>
|
||||
<Field
|
||||
required
|
||||
name='firstName'
|
||||
label='نام'
|
||||
component={FormInput}
|
||||
validate={persian}
|
||||
// normalize={normalizeName}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='lastName'
|
||||
label='نام خانوادگی'
|
||||
component={FormInput}
|
||||
validate={persian}
|
||||
// normalize={normalizeName}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='gender'
|
||||
label='جنسیت'
|
||||
component={FormSelect}
|
||||
options={GENDER_OPTIONS}
|
||||
validate={gender}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='type'
|
||||
label='وضعیت تحصیلی'
|
||||
component={FormSelect}
|
||||
options={STUDENT_TYPE_OPTIONS}
|
||||
validate={required}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='password'
|
||||
label='رمز عبور'
|
||||
type='password'
|
||||
component={FormInput}
|
||||
validate={password}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='rePassword'
|
||||
label='تائید رمز عبور'
|
||||
type='password'
|
||||
component={FormInput}
|
||||
validate={required}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className='col-span-2 mx-auto my-6'>
|
||||
<Field
|
||||
name='introducerCodeText'
|
||||
label='کد معرف'
|
||||
component={FormInput}
|
||||
maxLength={6}
|
||||
validate={introducerCode}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field
|
||||
name='notify'
|
||||
label={
|
||||
<Fragment>
|
||||
مایل به دریافت
|
||||
<span className='font-medium'> پیامک های اطلاع رسانی </span>
|
||||
سامانه فلش کارت های مدمشاور شامل
|
||||
<span className='font-medium'> پکیج های جدید </span>و
|
||||
<span className='font-medium'> کدهای تخفیف </span>
|
||||
هستم.
|
||||
</Fragment>
|
||||
}
|
||||
component={FormCheckbox}
|
||||
/>
|
||||
<Field
|
||||
required
|
||||
name='termsAndConditions'
|
||||
label={
|
||||
<Fragment>
|
||||
<Link to='/terms-and-conditions' className='text-indigo-600'>
|
||||
شرایط و قوانین{' '}
|
||||
</Link>
|
||||
<span>را مطالعه کردهام و آنها را میپذیرم.</span>
|
||||
</Fragment>
|
||||
}
|
||||
component={FormCheckbox}
|
||||
validate={requiredTermsAndConditions}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || isSubmitting}
|
||||
content='تایید'
|
||||
className='col-span-3 my-4 mr-auto ml-2'
|
||||
/>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ name: 'مرد', text: 'مرد', value: 'M' },
|
||||
{ name: 'زن', text: 'زن', value: 'F' }
|
||||
];
|
||||
|
||||
RegisterForm.propTypes = {
|
||||
mobile: PropTypes.string.isRequired,
|
||||
studentTypes: PropTypes.array.isRequired,
|
||||
authKey: PropTypes.string.isRequired,
|
||||
registerUser: PropTypes.func.isRequired,
|
||||
fetchIntroducerCode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, { registerUser, fetchIntroducerCode })(RegisterForm);
|
||||
106
src/components/score-chart/chart.js
Normal file
106
src/components/score-chart/chart.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Brush,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import createTrend from 'trendline';
|
||||
import moment from 'moment-jalaali';
|
||||
|
||||
function ScoreChart(props) {
|
||||
const scoreTypeName = _[props.scoreType];
|
||||
const length = props.data.length;
|
||||
const scoreThreshold = 4;
|
||||
const needScroll = length > scoreThreshold;
|
||||
const { slope, data } = trendData(props.data, 'number', 'score');
|
||||
const trendColor = slope === 0 ? BLUE : slope > 0 ? GREEN : RED;
|
||||
|
||||
return (
|
||||
<div className='flex justify-center'>
|
||||
<ResponsiveContainer
|
||||
width='100%'
|
||||
aspect={16 / 9}
|
||||
minWidth={300}
|
||||
debounce={1}
|
||||
className='max-w-[1000px]'
|
||||
>
|
||||
<LineChart data={data} margin={{ top: 20, right: 20, left: 20, bottom: 40 }}>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='score'
|
||||
stroke={BLUE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 1.5, strokeWidth: 3 }}
|
||||
/>
|
||||
<Line dataKey='score-trend' stroke={trendColor} strokeDasharray='3 3' />
|
||||
{needScroll && (
|
||||
<Brush
|
||||
dataKey='number'
|
||||
stroke={BLUE}
|
||||
height={20}
|
||||
startIndex={length - scoreThreshold}
|
||||
debounce={1}
|
||||
padding={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
labelFormatter={(number, data) => {
|
||||
if (!data.length) return null;
|
||||
const started = data[0].payload.started;
|
||||
return (
|
||||
<>
|
||||
<span>{`${scoreTypeName} ${number}`}</span>
|
||||
{started && (
|
||||
<>
|
||||
<br />
|
||||
<span>شروع: </span>
|
||||
<span>{moment(started).format('jYYYY/jMM/jDD')}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
formatter={(value, label) => [value.toFixed(2), _[label]]}
|
||||
/>
|
||||
<CartesianGrid stroke={GRAY} />
|
||||
<XAxis
|
||||
dataKey='number'
|
||||
tick={{ angle: needScroll ? 0 : -15, dy: needScroll ? 0 : 10 }}
|
||||
tickFormatter={number => `${scoreTypeName} ${number}`}
|
||||
label={{ value: 'زمان شروع', position: 'bottom', dy: needScroll ? 25 : 15 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ dx: -20 }}
|
||||
label={{ value: 'امتیاز', position: 'insideLeft', angle: -90 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const _ = { weekly: 'هفته', monthly: 'ماه', score: 'امتیاز', 'score-trend': 'امتیاز تخمینی' };
|
||||
const BLUE = '#3b82f6';
|
||||
const RED = '#ef4444';
|
||||
const GREEN = '#22c55e';
|
||||
const GRAY = '#ccc';
|
||||
|
||||
const trendData = (data, xKey, yKey) => {
|
||||
const trend = createTrend(data, xKey, yKey);
|
||||
return {
|
||||
slope: trend.slope,
|
||||
data: data.map(d => ({ ...d, [`${yKey}-trend`]: trend.calcY(d[xKey]) }))
|
||||
};
|
||||
};
|
||||
|
||||
ScoreChart.propTypes = {
|
||||
scoreType: PropTypes.string.isRequired,
|
||||
data: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export default ScoreChart;
|
||||
44
src/components/score-chart/container.js
Normal file
44
src/components/score-chart/container.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ScoreChart from './chart';
|
||||
import ScoreTab from './tab';
|
||||
import Fetcher from '../shared/fetches/fetcher';
|
||||
import { fetchScores } from '../../redux/actions/score';
|
||||
|
||||
function ScoreChartContainer() {
|
||||
const { scoreType } = useParams();
|
||||
|
||||
return (
|
||||
<Fetcher
|
||||
action={fetchScores.bind(null, scoreType)}
|
||||
stateSelector={state => state.scores}
|
||||
renderEmpty={() => (
|
||||
<p className='flex h-full flex-col items-center justify-center'>
|
||||
دادهای برای نمودار یافت نشد.
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
{scores => {
|
||||
const scoreData = [];
|
||||
let j = 0;
|
||||
for (let i = scores[0].number; i <= scores[scores.length - 1].number; i++) {
|
||||
if (i === scores[j].number) {
|
||||
scoreData.push(scores[j++]);
|
||||
} else {
|
||||
scoreData.push({ number: i, score: 0 });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<ScoreTab />
|
||||
<p className='py-4 text-lg font-medium'>{`نمودار امتیازهای ${_[scoreType]}`}</p>
|
||||
<ScoreChart scoreType={scoreType} data={scoreData} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Fetcher>
|
||||
);
|
||||
}
|
||||
|
||||
const _ = { weekly: 'هفتگی', monthly: 'ماهانه' };
|
||||
|
||||
export default ScoreChartContainer;
|
||||
12
src/components/score-chart/index.js
Normal file
12
src/components/score-chart/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import MainLayout from '../layouts/main';
|
||||
import ScoreChartContainer from './container';
|
||||
|
||||
function ScoreChartPage() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<ScoreChartContainer />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreChartPage;
|
||||
20
src/components/score-chart/tab.js
Normal file
20
src/components/score-chart/tab.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import ButtonGroup from '../shared/buttons/button-group';
|
||||
import NavLink from '../shared/buttons/nav-link';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
function ScoreTab() {
|
||||
const { scoreType } = useParams();
|
||||
|
||||
return (
|
||||
<ButtonGroup className='mx-auto mb-6 w-min'>
|
||||
<NavLink to='/charts/weekly' color={scoreType === 'weekly' ? 'blue' : 'gray'}>
|
||||
هفتگی
|
||||
</NavLink>
|
||||
<NavLink to='/charts/monthly' color={scoreType === 'monthly' ? 'blue' : 'gray'}>
|
||||
ماهانه
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreTab;
|
||||
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',
|
||||
'relative rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
content: PropTypes.node.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
23
src/components/shared/audio.js
Normal file
23
src/components/shared/audio.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './audio.module.css';
|
||||
|
||||
function Audio(props) {
|
||||
return (
|
||||
<figure className={styles['audio-container']}>
|
||||
{props.caption && (
|
||||
<figcaption className={`text-bold ${styles.caption}`}>{props.caption}</figcaption>
|
||||
)}
|
||||
<audio controls src={props.src}>
|
||||
Your browser does not support the
|
||||
<code>audio</code> element.
|
||||
</audio>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
Audio.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
caption: PropTypes.string
|
||||
};
|
||||
|
||||
export default Audio;
|
||||
14
src/components/shared/audio.module.css
Normal file
14
src/components/shared/audio.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.audio-container {
|
||||
margin: 1.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-container figcaption {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.audio-container audio {
|
||||
min-width: 200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
48
src/components/shared/buttons/back.js
Normal file
48
src/components/shared/buttons/back.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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 = {
|
||||
content: 'بازگشت',
|
||||
outline: false,
|
||||
flat: false,
|
||||
size: 'base',
|
||||
textSize: 'sm',
|
||||
color: 'blue',
|
||||
loading: false,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
BackLink.propTypes = {
|
||||
content: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
outline: PropTypes.bool.isRequired,
|
||||
flat: PropTypes.bool.isRequired,
|
||||
size: PropTypes.oneOf(['zero', 'xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
textSize: PropTypes.oneOf(['xs', 'sm', 'base', 'lg', 'xl']).isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default BackLink;
|
||||
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;
|
||||
}
|
||||
27
src/components/shared/buttons/button-group.js
Normal file
27
src/components/shared/buttons/button-group.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import './button-group.css';
|
||||
|
||||
function ButtonGroup(props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'btn-group flex',
|
||||
{ '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}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user