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

View File

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

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import OptionContainer from './option-container';
import Question from './question';
import Answer from './answer';
import Reviews from './reviews';
import Header from './header';
import Footer from './footer';
import { CORRECT_COLOR, DEFAULT_COLOR } from './utils';
import { updateStudentCardRate } from '../../redux/actions/student-card';
function Card(props) {
const { card } = props;
const [color, setColor] = useState(DEFAULT_COLOR);
const initialRate = props.card.studentCard?.rate || 0;
const [rate, setRate] = useState(initialRate);
useEffect(async () => {
if (rate !== initialRate && card.studentCard) {
await props.updateStudentCardRate(card.studentCard.id, rate);
}
}, [rate]);
return (
<div className='mb-20'>
<Header chapter={props.chapter} lessonName={props.card.lesson.name} />
<Question
cardId={card.id}
question={card.question}
color={color}
rate={rate}
setRate={setRate}
/>
<OptionContainer
cardId={card.id}
options={card.options}
setColor={setColor}
rate={rate}
rateUpdated={rate !== initialRate}
/>
{color !== DEFAULT_COLOR && <Reviews color={color} />}
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
<Footer isAnyOptionSelected={color !== DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
</div>
);
}
Card.propTypes = {
card: PropTypes.object.isRequired,
chapter: PropTypes.object.isRequired,
updateStudentCardRate: PropTypes.func.isRequired
};
export default connect(null, { updateStudentCardRate })(Card);

View File

@@ -0,0 +1,32 @@
import { useParams } from 'react-router-dom';
import Card from './card';
import Error from './error';
import MultiFetcher from '../shared/fetches/multi-fetcher';
import { fetchNextCard } from '../../redux/actions/card';
import { fetchChapter } from '../../redux/actions/chapter';
function CardsContainer() {
const { chapterId, cardType } = useParams();
const urlSearchParams = new URLSearchParams(location.search);
const priority = urlSearchParams.get('priority');
const rateType = urlSearchParams.get('rate-type');
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : null;
return (
<MultiFetcher
actions={[
fetchNextCard.bind(null, chapterId, cardType, priority, minRate),
fetchChapter.bind(null, chapterId, priority, minRate)
]}
stateSelectors={{
card: state => state.card,
chapter: state => state.chapter
}}
renderError={error => <Error error={error} />}
>
{({ card, chapter }) => <Card card={card} chapter={chapter} />}
</MultiFetcher>
);
}
export default CardsContainer;

View File

@@ -0,0 +1,80 @@
import { useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import NavLink from '../shared/buttons/nav-link';
import ErrorComponent from '../shared/error';
function Error(props) {
const { cardType } = useParams();
const urlSearchParams = new URLSearchParams(location.search);
const priority = urlSearchParams.get('priority');
const rateType = urlSearchParams.get('rate-type');
const { error } = props;
const backURL = `/decks?${urlSearchParams.toString()}`;
if (error.card && error.card.status === 404) {
switch (cardType) {
case 'review':
return (
<Segment>
<p className='my-4 text-center text-xl'>
<span>فلش کارت </span>
<span className='font-bold'>{'مروری '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-row justify-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'new':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'جدید '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'archived':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'آرشیو شده '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center gap-3'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
default:
return null;
}
}
return <ErrorComponent />;
}
function _(text) {
return !text ? '' : { starred: 'ستاره دار', active: 'فعال', high: 'ضروری ' }[text];
}
Error.propTypes = {
error: PropTypes.object.isRequired
};
export default Error;

View File

@@ -0,0 +1,98 @@
import { Fragment, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
import Button from '../shared/buttons/button';
import { archiveStudentCard } from '../../redux/actions/student-card';
import ConfirmModal from '../shared/confirm-modal';
import NavLink from '../shared/buttons/nav-link';
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
function Footer(props) {
const history = useHistory();
const { chapterId, cardType } = useParams();
const [showConfirmModal, setShowConfirmModal] = useState(false);
let previousURL = `/reviews/last/ch-${chapterId}/${cardType}`;
let nextURL = `/cards/ch-${chapterId}/${cardType}`;
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
if (urlSearchParamsSTR) {
previousURL += `?${urlSearchParamsSTR}`;
nextURL += `?${urlSearchParamsSTR}`;
}
const archiveText = props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت';
return (
<Fragment>
<ConfirmModal
showModal={showConfirmModal}
content={
<p>
<span>آیا از </span>
<span className='font-medium'>{archiveText}</span>
<span> این فلش کارت مطمئن هستید؟</span>
</p>
}
onConfirm={async () => {
await props.archiveStudentCard();
history.push(nextURL);
}}
onCancel={() => setShowConfirmModal(false)}
/>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
}
>
{props.isAnyOptionSelected ? (
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
بازگشت
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</Button>
) : (
<NavLink to={previousURL} color='red' className='m-2'>
قبلی
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</NavLink>
)}
{props.showArchive && (
<Button
color={props.isPermanentArchive ? 'indigo' : 'blue'}
onClick={() => setShowConfirmModal(true)}
className='m-2'
>
{props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت'}
</Button>
)}
<NavLink
replace
to={nextURL}
color='green'
textColor='white'
disabled={!props.isAnyOptionSelected}
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
</div>
</Fragment>
);
}
Footer.propTypes = {
isAnyOptionSelected: PropTypes.bool.isRequired,
showArchive: PropTypes.bool.isRequired,
archiveStudentCard: PropTypes.func.isRequired,
isPermanentArchive: PropTypes.bool.isRequired
};
function mapStateToProps({ review }, { showArchive }) {
return {
isPermanentArchive: !!review.data.studentCard && review.data.studentCard.status === 1,
showArchive: showArchive && !!review.data.studentCard && review.data.studentCard.status !== 2
};
}
export default connect(mapStateToProps, { archiveStudentCard })(Footer);

View File

@@ -0,0 +1,38 @@
import { useParams } from 'react-router-dom';
import Segment from '../shared/segment';
import PropTypes from 'prop-types';
import useQueryParams from '../../hooks/query-params';
function Header(props) {
const { cardType } = useParams();
const { priority } = useQueryParams();
return (
<Segment textAlign='center' color={color[cardType]} backgroundColor={color[cardType]}>
<div className='flex flex-wrap items-center justify-around gap-3 xl:justify-between'>
<div>
<span>فلش کارتهای </span>
{priority && <span className='font-medium text-red-600'>{_[priority] + ' '}</span>}
<span className={`font-medium text-${color[cardType]}-600`}>{_[cardType]}</span>
<span> درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> بخش </span>
<span className='font-medium'>{props.chapter.name}</span>
</div>
<div className={`rounded-full bg-${color[cardType]}-500 py-1 px-2.5 text-white`}>
<span className='font-medium'>{props.chapter[`${cardType}CardCount`]}</span>
</div>
</div>
</Segment>
);
}
const _ = { review: 'مروری', new: 'جدید', archived: 'آرشیو شده', high: 'ضروری ' };
const color = { review: 'blue', new: 'green', archived: 'yellow' };
Header.propTypes = {
lessonName: PropTypes.string.isRequired,
chapter: PropTypes.object.isRequired
};
export default Header;

View File

@@ -0,0 +1,26 @@
import { LightBulbIcon as OutlineLightBulbIcon } from '@heroicons/react/outline';
import { LightBulbIcon as SolidLightBulbIcon } from '@heroicons/react/solid';
import PropTypes from 'prop-types';
function HideIcon({ hide, onUpdate }) {
return hide ? (
<OutlineLightBulbIcon
width={30}
onClick={onUpdate}
className='text-gray-300 hover:text-gray-400'
/>
) : (
<SolidLightBulbIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
);
}
HideIcon.propTypes = {
hide: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default HideIcon;

View File

@@ -0,0 +1,22 @@
import PropTypes from 'prop-types';
import StarIcon from './star';
import HideIcon from './hide';
import ReportIcon from './report';
function Icons({ cardId, rate, setRate }) {
return (
<div className='flex items-center justify-center gap-2'>
{rate >= 0 && <StarIcon star={rate > 0} onUpdate={() => setRate(rate => 1 - rate)} />}
{rate <= 0 && <HideIcon hide={rate < 0} onUpdate={() => setRate(rate => -1 - rate)} />}
<ReportIcon cardId={cardId} />
</div>
);
}
Icons.propTypes = {
cardId: PropTypes.number.isRequired,
rate: PropTypes.number,
setRate: PropTypes.func.isRequired
};
export default Icons;

View File

@@ -0,0 +1,27 @@
import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon';
import PropTypes from 'prop-types';
import { useState } from 'react';
import ReportModal from '../report-modal';
function ReportIcon({ cardId }) {
const [showModal, setShowModal] = useState(false);
return (
<>
<ExclamationIcon
width={30}
onClick={() => setShowModal(!showModal)}
className='text-orange-400 hover:text-orange-500'
/>
{showModal && (
<ReportModal cardId={cardId} showModal={showModal} onClose={() => setShowModal(false)} />
)}
</>
);
}
ReportIcon.propTypes = {
cardId: PropTypes.number.isRequired
};
export default ReportIcon;

View File

@@ -0,0 +1,22 @@
import { StarIcon as OutlineStarIcon } from '@heroicons/react/outline';
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid';
import PropTypes from 'prop-types';
function StarIcon({ star, onUpdate }) {
return star ? (
<SolidStarIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
) : (
<OutlineStarIcon width={30} onClick={onUpdate} className='text-gray-300 hover:text-gray-400' />
);
}
StarIcon.propTypes = {
star: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default StarIcon;

View File

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

View File

@@ -0,0 +1,60 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Option from './option';
import { createReview } from '../../redux/actions/review';
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
import { updateStudentCardRate } from '../../redux/actions/student-card';
class OptionContainer extends Component {
constructor(props) {
super(props);
this.state = {
selectedOptionId: null,
options: [...props.options, { id: 0, text: 'نمی دانم', isCorrect: false }]
};
}
handleSelect = async optionId => {
if (this.state.selectedOptionId !== null) return null;
this.setState({ selectedOptionId: optionId });
if (optionId !== 0) this.setState({ options: [...this.props.options] });
const { payload: review } = await this.props.createReview(this.props.cardId, optionId);
if (this.props.rateUpdated) {
await this.props.updateStudentCardRate(review.studentCard.id, this.props.rate);
}
this.setState({ selectedOptionId: optionId });
this.props.setColor(
optionId ? (review.status === 1 ? CORRECT_COLOR : WRONG_COLOR) : DONT_KNOW_COLOR
);
};
render() {
return (
<div id='options' className='space-y-3 px-4'>
{this.state.options.map((option, index) => (
<Option
key={option.id}
option={option}
index={index}
isAnyOptionSelected={this.state.selectedOptionId !== null}
isSelectedOption={this.state.selectedOptionId === option.id}
onSelect={this.handleSelect}
/>
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired,
setColor: PropTypes.func.isRequired,
createReview: PropTypes.func.isRequired,
rate: PropTypes.number,
rateUpdated: PropTypes.bool.isRequired,
updateStudentCardRate: PropTypes.func.isRequired
};
export default connect(null, { createReview, updateStudentCardRate })(OptionContainer);

View File

@@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { ExclamationIcon } from '@heroicons/react/outline';
import { CheckIcon, XIcon } from '@heroicons/react/solid';
import Button from '../shared/buttons/button';
function Option(props) {
const { option, isAnyOptionSelected, isSelectedOption } = props;
const color =
isAnyOptionSelected && option.isCorrect
? 'green'
: isAnyOptionSelected && isSelectedOption && option.id
? 'red'
: option.id
? 'gray'
: 'yellow';
return (
<Button
id={`option ${option.id}`}
outline={!isAnyOptionSelected}
color={color}
colorWeight={300}
borderWeight={2}
disabled={isAnyOptionSelected}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
onClick={() => props.onSelect(option.id)}
className={classNames('w-full', { [`border-2 border-${color}-300`]: isAnyOptionSelected })}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
{isSelectedOption &&
(!option.id ? (
<ExclamationIcon
width={25}
height={25}
className={`shrink-0 stroke-current text-yellow-600`}
/>
) : option.isCorrect ? (
<CheckIcon
width={25}
height={25}
className={`shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
))}
</Button>
);
}
Option.propTypes = {
option: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
isAnyOptionSelected: PropTypes.bool.isRequired,
isSelectedOption: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired
};
export default Option;

View File

@@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import { DEFAULT_COLOR } from './utils';
import Icons from './icons';
function Question(props) {
return (
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
<div>
<div className='mb-4 flex w-full items-center '>
<div className='flex-1 font-semibold'>سوال:</div>
<div className='col-span-1'>
<Icons cardId={props.cardId} rate={props.rate} setRate={props.setRate} />
</div>
</div>
<div dangerouslySetInnerHTML={{ __html: props.question }} />
</div>
</Segment>
);
}
Question.defaultProps = { color: DEFAULT_COLOR };
Question.propTypes = {
cardId: PropTypes.number.isRequired,
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired,
rate: PropTypes.number,
setRate: PropTypes.func.isRequired
};
export default Question;

View File

@@ -0,0 +1,96 @@
import { connect } from 'react-redux';
import { Field, Form, Formik } from 'formik';
import PropTypes from 'prop-types';
import Modal from '../shared/modal';
import FormSelect from '../shared/forms/select';
import FormTextArea from '../shared/forms/textarea';
import Button from '../shared/buttons/button';
import { required } from '../shared/forms/validations';
import { createReport } from '../../redux/actions/report';
import { useToasts } from 'react-toast-notifications';
import { useState } from 'react';
import Segment from '../shared/segment';
function ReportModal(props) {
const [error, setError] = useState('');
const { addToast } = useToasts();
async function submit(values) {
try {
setError('');
await props.createReport(values);
addToast('با تشکر، گزارش خطای شما ثبت گردید.', { appearance: 'success' });
props.onClose();
} catch (e) {
setError(e.statusText);
}
}
return (
<Modal
showModal={props.showModal}
onClose={props.onClose}
header='فرم گزارش خطا'
body={
<Formik initialValues={{ card: props.cardId, type: '', description: '' }} onSubmit={submit}>
<Form id='report-form' className='flex flex-col gap-3'>
{error && (
<Segment
color='red'
backgroundColor='red'
className='text-center font-medium text-red-500'
>
<p>{error}</p>
<p>لطفا مدتی بعد مجددا تلاش کنید.</p>
</Segment>
)}
<input type='hidden' id='card' name='card' />
<Field
required
name='type'
label='نوع خطا'
component={FormSelect}
validate={required}
options={types}
/>
<Field
required
name='description'
label='توضیحات'
component={FormTextArea}
validate={required}
rows={5}
/>
</Form>
</Formik>
}
footer={
<div className='mx-3 flex justify-between gap-2'>
<Button
type='submit'
form='report-form'
color='green'
content='ثبت گزارش'
className='grow'
/>
<Button color='red' content='بازگشت' onClick={props.onClose} className='grow' />
</div>
}
/>
);
}
const types = [
{ name: 'Scientific', text: 'علمی', value: 'Scientific' },
{ name: 'Typo', text: 'نگارشی', value: 'Typo' },
{ name: 'Other', text: 'سایر', value: 'Other' }
];
ReportModal.propTypes = {
cardId: PropTypes.number.isRequired,
showModal: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
createReport: PropTypes.func.isRequired
};
export default connect(null, { createReport })(ReportModal);

View File

@@ -0,0 +1,77 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { ExclamationIcon } from '@heroicons/react/outline';
import { CheckIcon, XIcon } from '@heroicons/react/solid';
import Segment from '../shared/segment';
function Reviews(props) {
const { color, studentCard } = props;
return (
<Segment id='history' color={color} colorWeight={300} borderWeight={2}>
<div className='flex flex-wrap justify-around gap-4'>
<p>
<span className='font-medium'>شناسه فلش کارت: </span>
{studentCard.card}
</p>
{studentCard.status > 0 && (
<p>
<span className='font-medium'>وضعیت: </span>
{statuses[studentCard.status]}
</p>
)}
<p>
<span className='font-medium'>مرور شماره: </span>
{studentCard.reviewCount}
</p>
<div className='flex flex-wrap justify-center gap-x-2 gap-y-4'>
<span className='font-medium'>نتایج: </span>
<div>
{studentCard.reviews.map(review => (
<span key={review.id} className='px-px'>
{!review.status ? (
<ExclamationIcon
width={25}
height={25}
className={`inline-flex shrink-0 stroke-current text-yellow-500`}
/>
) : review.status === 1 ? (
<CheckIcon
width={25}
height={25}
className={`inline-flex shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`inline-flex shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
)}
</span>
))}
</div>
</div>
</div>
</Segment>
);
}
const statuses = ['عادی', 'آرشیو موقت', 'آرشیو دائم'];
Reviews.propTypes = {
studentCard: PropTypes.object.isRequired,
color: PropTypes.string.isRequired
};
function mapStateToProps({ review }) {
return { studentCard: review.data.studentCard };
}
export default connect(mapStateToProps)(Reviews);

View File

@@ -0,0 +1,4 @@
export const DEFAULT_COLOR = 'gray';
export const CORRECT_COLOR = 'green';
export const WRONG_COLOR = 'red';
export const DONT_KNOW_COLOR = 'yellow';

View File

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

View File

@@ -0,0 +1,30 @@
import PropTypes from 'prop-types';
import Header from './header';
import Question from './question';
import OptionContainer from './option-container';
import Answer from './answer';
import Footer from './footer';
function Card(props) {
const { card } = props;
return (
<div className='mb-20'>
<Header
cardId={card.id}
chapterName={props.card.chapter.name}
lessonName={props.card.lesson.name}
/>
<Question question={card.question} />
<OptionContainer cardId={card.id} options={card.options} />
<Answer answer={card.answer} />
<Footer />
</div>
);
}
Card.propTypes = {
card: PropTypes.object.isRequired
};
export default Card;

View File

@@ -0,0 +1,20 @@
import { Fragment } from 'react';
import { useParams } from 'react-router-dom';
import Card from './card';
import Fetcher from '../shared/fetches/fetcher';
import { fetchCard } from '../../redux/actions/card';
import Footer from './footer';
function CardsContainer() {
const { cardId } = useParams();
return (
<Fragment>
<Fetcher action={fetchCard.bind(null, cardId)} stateSelector={state => state.card}>
{card => <Card card={card} />}
</Fetcher>
<Footer />
</Fragment>
);
}
export default CardsContainer;

View File

@@ -0,0 +1,48 @@
import { Fragment } from 'react';
import { useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
import { fetchCard } from '../../redux/actions/card';
import NavLink from '../shared/buttons/nav-link';
function Footer() {
const { cardId } = useParams();
return (
<Fragment>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row justify-between bg-black bg-opacity-60'
}
>
<NavLink
replace
to={`/cards/${parseInt(cardId) + 1}/preview`}
color='green'
textColor='white'
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
{cardId > 1 && (
<NavLink
replace
to={`/cards/${parseInt(cardId) - 1}/preview`}
color='red'
className='m-2'
>
قبلی
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</NavLink>
)}
</div>
</Fragment>
);
}
Footer.propTypes = {
fetchCard: PropTypes.func.isRequired
};
export default connect(null, { fetchCard })(Footer);

View File

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

View File

@@ -0,0 +1,10 @@
import { useSelector } from 'react-redux';
import MainLayout from '../layouts/main';
import CardsContainer from './container';
function CardPage() {
const { isSuperuser } = useSelector(({ user }) => user.data);
return <MainLayout withFooter>{isSuperuser ? <CardsContainer /> : null}</MainLayout>;
}
export default CardPage;

View File

@@ -0,0 +1,36 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import Option from './option';
class OptionContainer extends Component {
constructor(props) {
super(props);
this.state = {
options: [
...props.options,
{
id: 0,
text: 'نمی دانم',
isCorrect: false
}
]
};
}
render() {
return (
<div id='options' className='space-y-3 px-4'>
{this.state.options.map((option, index) => (
<Option key={option.id} option={option} index={index} />
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default OptionContainer;

View File

@@ -0,0 +1,41 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Button from '../shared/buttons/button';
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
function Option(props) {
const { option } = props;
const color = option.isCorrect ? CORRECT_COLOR : option.id ? WRONG_COLOR : DONT_KNOW_COLOR;
return (
<Button
id={`option ${option.id}`}
disabled
outline={false}
color={color}
colorWeight={300}
borderWeight={2}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
className={`w-full border-2 border-${color}-300`}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
</Button>
);
}
Option.propTypes = {
option: PropTypes.object.isRequired,
index: PropTypes.number.isRequired
};
export default Option;

View File

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

View File

@@ -0,0 +1,4 @@
export const DEFAULT_COLOR = 'gray';
export const CORRECT_COLOR = 'green';
export const WRONG_COLOR = 'red';
export const DONT_KNOW_COLOR = 'yellow';

View File

@@ -0,0 +1,37 @@
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Badge(props) {
const { color } = props;
if (props.number === 0) return null;
const content = (
<div
className={classNames(
`bg-${color}-500 flex items-center rounded-xl px-1 py-0.5 leading-none text-white`,
`border border-opacity-50 border-${color}-600`,
{ 'cursor-not-allowed': !props.to }
)}
>
<span className='m-1 text-xs font-medium'>{props.text}</span>
<span className={`bg-white text-xs text-${color}-700 m-1 rounded-xl py-0.5 px-1`}>
{props.number}
</span>
</div>
);
if (!props.to) return content;
return <Link to={props.to}>{content}</Link>;
}
Badge.defaultProps = { to: '', color: 'blue' };
Badge.propTypes = {
text: PropTypes.string.isRequired,
number: PropTypes.number.isRequired,
to: PropTypes.string.isRequired,
color: PropTypes.string.isRequired
};
export default Badge;

View File

@@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import Modal from '../shared/modal';
import Button from '../shared/buttons/button';
import NavLink from '../shared/buttons/nav-link';
function ChapterModal(props) {
const { chapter, priority, rateType } = props;
if (!chapter) return null;
const noCards =
chapter.reviewCardCount === 0 && chapter.newCardCount === 0 && chapter.archivedCardCount === 0;
function getURL(cardType) {
const url = `/cards/ch-${chapter.id}/${cardType}`;
const urlSearchParams = new URLSearchParams();
if (priority) urlSearchParams.set('priority', priority);
if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType);
const urlSearchParamsSTR = urlSearchParams.toString();
if (!urlSearchParamsSTR) return url;
return url + `?${urlSearchParamsSTR}`;
}
return (
<Modal
showModal
onClose={props.onClose}
header='دسترسی به فلش کارت‌ها'
body={
<div className='text-base'>
<p>
<span>شما فصل </span>
<span className='font-medium'>{chapter.name}</span>
<span> از درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> را انتخاب کرده اید.</span>
</p>
{noCards ? (
<div>
<p>تمام فلش کارت های جدید و مروری این درس را مطالعه کرده اید.</p>
<p>مرورهای بعدی این درس در روزهای آینده فعال خواهد شد.</p>
</div>
) : (
<p>
با استفاده از دکمههای زیر میتوانید به فلش کارتهای مورد نظر خود دسترسی داشته باشید.
</p>
)}
</div>
}
footer={
<div className='flex flex-wrap justify-end gap-2'>
{chapter.reviewCardCount > 0 && (
<NavLink to={getURL('review')} size='sm' color='red'>
<span className='m-1 text-xs font-medium'>مروری</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-red-800'>
{chapter.reviewCardCount}
</span>
</NavLink>
)}
{chapter.newCardCount > 0 && (
<NavLink to={getURL('new')} size='sm' color='yellow'>
<span className='align-middle text-xs font-medium'>جدید</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-yellow-800'>
{chapter.newCardCount}
</span>
</NavLink>
)}
{chapter.archivedCardCount > 0 && (
<NavLink to={getURL('archived')} size='sm' color='blue'>
<span className='text-xs font-medium'>آرشیو شده</span>
<span className='m-1 rounded-xl bg-white py-0.5 px-1 text-xs text-blue-800'>
{chapter.archivedCardCount}
</span>
</NavLink>
)}
{noCards && <Button color='red' content='بازگشت' onClick={props.onClose} />}
</div>
}
/>
);
}
ChapterModal.propTypes = {
chapter: PropTypes.object,
lessonName: PropTypes.string,
priority: PropTypes.string,
rateType: PropTypes.string,
onClose: PropTypes.func.isRequired
};
export default ChapterModal;

View File

@@ -0,0 +1,27 @@
import { useLocation } from 'react-router-dom';
import StudentDecks from './student-decks';
import Fetcher from '../shared/fetches/fetcher';
import NoDeck from './no-deck';
import { fetchStudentDecks } from '../../redux/actions/student-deck';
function DecksContainer() {
const location = useLocation();
const searchPrams = new URLSearchParams(location.search);
const priority = searchPrams.get('priority');
const rateType = searchPrams.get('rate-type');
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1;
return (
<Fetcher
action={() => fetchStudentDecks(priority, minRate)}
stateSelector={state => state.studentDecks}
renderEmpty={() => <NoDeck />}
>
{studentDecks => (
<StudentDecks priority={priority} rateType={rateType} studentDecks={studentDecks} />
)}
</Fetcher>
);
}
export default DecksContainer;

View File

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

View File

@@ -0,0 +1,27 @@
import { Link } from 'react-router-dom';
import Segment from '../shared/segment';
import NavLink from '../shared/buttons/nav-link';
function NoDeck() {
return (
<Segment className='leading-8'>
<p className='text-center'>داوطلب گرامی، شما هیچ بسته فلش کارت فعالی ندارید.</p>
<p className='text-center'>
لطفا برای خرید بسته فلش کارت مورد نظر خود به
<Link to='/store' className='text-blue-600'>
{' '}
فروشگاه{' '}
</Link>
مراجعه کنید.
</p>
<p className='text-center'>
<span>همچنین با مراجعه به فروشگاه میتوانید از </span>
<span className='font-medium text-pink-500'>بستههای تستی رایگان</span>
<span> مدمشاور نیز استفاده کنید.</span>
</p>
<NavLink to='/store' color='green' content='فروشگاه' className='mx-auto my-4' />
</Segment>
);
}
export default NoDeck;

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import ResetModalStepOne from './step-one';
import ResetModalStepTwo from './step-two';
import ResetModalStepSuccess from './step-success';
import { connect } from 'react-redux';
import { createResetRequest } from '../../../redux/actions/reset-request';
import { fetchStudentDecks } from '../../../redux/actions/student-deck';
import ResetModalStepError from './step-error';
function ResetModal(props) {
const [step, setStep] = useState(1);
const [error, setError] = useState(null);
const { itemType, itemId, itemName, onClose } = props;
const itemLabel = `${_[itemType]} ${itemName}`;
if (error) {
return (
<ResetModalStepError
itemLabel={itemLabel}
error={error}
onClose={() => {
setStep(1);
onClose();
}}
/>
);
}
if (step === 1) {
return <ResetModalStepOne itemLabel={itemLabel} onClose={onClose} onNext={() => setStep(2)} />;
}
if (step === 2) {
return (
<ResetModalStepTwo
onClose={onClose}
onNext={async () => {
try {
await props.createResetRequest(itemType, itemId);
setStep(0);
} catch (error) {
setError(error);
}
}}
/>
);
}
if (step === 0) {
return (
<ResetModalStepSuccess
itemLabel={itemLabel}
onClose={() => {
setStep(1);
props.fetchStudentDecks();
onClose();
}}
/>
);
}
return null;
}
const _ = { chapter: 'فصل', lesson: 'درس', deck: 'بسته' };
ResetModal.propTypes = {
itemType: PropTypes.oneOf(['chapter', 'lesson', 'deck']).isRequired,
itemId: PropTypes.number.isRequired,
itemName: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
createResetRequest: PropTypes.func.isRequired,
fetchStudentDecks: PropTypes.func.isRequired
};
export default connect(null, { createResetRequest, fetchStudentDecks })(ResetModal);

View File

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

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import Modal from '../../shared/modal';
import Button from '../../shared/buttons/button';
import { InformationCircleIcon } from '@heroicons/react/outline';
import Audio from '../../shared/audio';
function ResetModalStepOne(props) {
const [showGuide, setShowGuide] = useState(false);
function toggleShowGuide() {
setShowGuide(showGuide => !showGuide);
}
return (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>
<span>شما در حال </span>
<span className='font-semibold text-red-500'>بازنشانی </span>
<span>فلش کارت های </span>
<span className='font-semibold'>{props.itemLabel}</span>
<span> هستید.</span>
</p>
<p>در صورت تایید تمام سوابق مرور فلش کارت های این مبحث حذف خواهد شد.</p>
<p>
همچنین امتیازاتی که از مطالعه فلش کارت های این بخش کسب شده از بین خواهد رفت و ممکن از
رتبه شما در رتبه بندی هفتگی و ماهانه تغییر کند.
</p>
<p className='font-medium'>آیا مطمئن هستید؟</p>
{showGuide && (
<>
<hr className='my-4' />
<div id='guide'>
<Audio src='/voices/reset-guide.mp3' caption='فایل صوتی توضیحات بازنشانی (ریست):' />
</div>
</>
)}
</div>
}
footer={
<div className='flex justify-between'>
<Button circular color='gray' colorWeight={400} onClick={toggleShowGuide}>
<InformationCircleIcon width={20} height={20} />
</Button>
<div className='flex gap-1'>
<Button color='green' content='بلی' onClick={props.onNext} />
<Button color='red' content='خیر' onClick={props.onClose} />
</div>
</div>
}
/>
);
}
ResetModalStepOne.propTypes = {
itemLabel: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired
};
export default ResetModalStepOne;

View File

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

View File

@@ -0,0 +1,71 @@
import { useRef, useState } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Modal from '../../shared/modal';
import Button from '../../shared/buttons/button';
import { Field, Form, Formik } from 'formik';
import FormInput from '../../shared/forms/input';
function ResetModalStepTwo(props) {
const [isCorrectCode, setIsCorrectCode] = useState(false);
const formRef = useRef(null);
return (
<Modal
showModal
onClose={props.onClose}
header={<span className='text-red-400'>بازنشانی فلش کارتها</span>}
body={
<div className='text-base'>
<p>برای تایید 4 رقم آخر شماره تلفن همراه خود را وارد نمایید:</p>
<Formik
innerRef={formRef}
initialValues={{ code: '' }}
validate={({ code }) => {
const isCorrect = code === props.correctCode;
setIsCorrectCode(isCorrect);
if (code.length !== 4) {
return { code: '4 رقم آخر شماره تلفن همراه خود را وارد نمایید.' };
}
if (!isCorrect) return { code: 'کد وارد شده اشتباه است.' };
}}
onSubmit={props.onNext}
>
<Form className='grid grid-cols-1 gap-5 p-3'>
<Field
required
name='code'
label='4 رقم آخر شماره تلفن همراه'
component={FormInput}
maxLength={4}
/>
</Form>
</Formik>
</div>
}
footer={
<div className='flex justify-end gap-1'>
<Button
disabled={!isCorrectCode}
color='green'
content='تایید'
onClick={formRef.current?.submitForm}
/>
<Button color='red' content='بازگشت' onClick={props.onClose} />
</div>
}
/>
);
}
ResetModalStepTwo.propTypes = {
onClose: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired,
correctCode: PropTypes.string.isRequired
};
function mapStateToProps({ user }) {
return { correctCode: user.data.mobile.slice(-4) };
}
export default connect(mapStateToProps)(ResetModalStepTwo);

View File

@@ -0,0 +1,50 @@
import Fetcher from '../../shared/fetches/fetcher';
import PropTypes from 'prop-types';
import StudentDeck from './student-deck';
import Loader from '../../shared/loader';
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchStudentDeck } from '../../../redux/actions/student-deck';
function StudentDeckContainer({ studentDeckId, onSelectChapter, onRefreshSelected }) {
const location = useLocation();
const urlSearchParams = new URLSearchParams(location.search);
const rateType = urlSearchParams.get('rate-type');
const priority = urlSearchParams.get('priority');
const action = useCallback(() => {
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : -1;
return fetchStudentDeck(studentDeckId, priority, minRate);
}, [studentDeckId, priority, rateType]);
const renderLoader = useCallback(
() => (
<div className='border border-t-0 border-gray-500 text-center'>
<Loader full={false} />
</div>
),
[]
);
return (
<Fetcher action={action} stateSelector={state => state.studentDeck} renderLoader={renderLoader}>
{studentDeck => (
<StudentDeck
priority={priority}
rateType={rateType}
studentDeck={studentDeck}
onSelectChapter={onSelectChapter}
onRefreshSelected={onRefreshSelected}
/>
)}
</Fetcher>
);
}
StudentDeckContainer.propTypes = {
studentDeckId: PropTypes.number.isRequired,
onSelectChapter: PropTypes.func.isRequired,
onRefreshSelected: PropTypes.func.isRequired
};
export default StudentDeckContainer;

View File

@@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Badge from '../badge';
import Button from '../../shared/buttons/button';
import { RefreshIcon } from '@heroicons/react/outline';
import { compareChapter } from '../utils';
function StudentDeck({ priority, rateType, studentDeck, onSelectChapter, onRefreshSelected }) {
function getURL(chapterId, cardType) {
const url = `/cards/ch-${chapterId}/${cardType}`;
const urlSearchParams = new URLSearchParams();
if (priority) urlSearchParams.set('priority', priority);
if (cardType !== 'new' && rateType) urlSearchParams.set('rate-type', rateType);
const urlSearchParamsSTR = urlSearchParams.toString();
if (!urlSearchParamsSTR) return url;
return url + `?${urlSearchParamsSTR}`;
}
return studentDeck.deck.chapters.sort(compareChapter).map((chapter, index, array) => (
<div
key={chapter.id}
onClick={e => {
e.stopPropagation();
onSelectChapter(chapter);
}}
className={classNames('border border-t-0 border-gray-500 text-center', {
'rounded-b-lg': index === array.length - 1
})}
>
<div className='relative grid grid-flow-col grid-cols-9 p-4 hover:bg-gray-100 sm:grid-flow-row'>
<div className='row-span-3 m-auto'>({index + 1})</div>
<div className='col-span-8 m-auto sm:col-span-3'>{chapter.name}</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
<Badge
text='مروری'
number={chapter.reviewCardCount}
to={getURL(chapter.id, 'review')}
color='red'
/>
<Badge
text='جدید'
number={chapter.newCardCount}
to={getURL(chapter.id, 'new')}
color='yellow'
/>
</div>
{!rateType && (
<div className='row-span-3 m-auto font-bold'>
<Button
outline
circular
size='xs'
color='orange'
disabled={chapter.cardCount === chapter.newCardCount}
onClick={e => {
e.stopPropagation();
onRefreshSelected({ id: chapter.id, type: 'chapter', name: chapter.name });
}}
>
<RefreshIcon width={20} height={20} />
</Button>
</div>
)}
</div>
</div>
));
}
StudentDeck.propTypes = {
priority: PropTypes.string,
rateType: PropTypes.string,
studentDeck: PropTypes.object.isRequired,
onSelectChapter: PropTypes.func.isRequired,
onRefreshSelected: PropTypes.func.isRequired
};
export default StudentDeck;

View File

@@ -0,0 +1,167 @@
import { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
InformationCircleIcon,
MinusCircleIcon,
PlusCircleIcon,
RefreshIcon
} from '@heroicons/react/outline';
import { getStudentDeckComparator } from './utils';
import Badge from './badge';
import Button from '../shared/buttons/button';
import ChapterModal from './chapter-modal';
import ResetModal from './reset-modal';
import StudentDeckContainer from './student-deck/container';
class StudentDecks extends Component {
constructor(props) {
super(props);
this.state = {
selectedStudentDeck: props.studentDecks.length === 1 ? props.studentDecks[0] : null,
selectedChapter: null,
selectedRefreshItem: null
};
}
isSelectedStudentDeck = studentDeckId => this.state.selectedStudentDeck?.id === studentDeckId;
handleSelectStudentDeck = studentDeck =>
this.setState(state => ({
selectedStudentDeck: state.selectedStudentDeck?.id === studentDeck.id ? null : studentDeck
}));
handleSelectChapter = chapter =>
this.setState(state => ({
selectedChapter: state.selectedChapter?.id === chapter?.id ? null : chapter
}));
handleRefreshSelected = item =>
this.setState(state => ({
selectedRefreshItem: state.selectedRefreshItem?.id === item?.id ? null : item
}));
getStudentDeckColor = studentDeck => {
if (studentDeck.reviewCardCount !== 0) return 'red';
return studentDeck.newCardCount !== 0 ? 'blue' : 'green';
};
render() {
const comparator = getStudentDeckComparator(this.props.priority);
return (
<Fragment>
<div className='mx-4 rounded-lg'>
{this.props.studentDecks.sort(comparator).map(studentDeck => {
if (this.props.priority && !studentDeck.deck.prioritized) {
return (
<div key={studentDeck.id} className='mb-1'>
<div
className={classNames(
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
`bg-gray-100`,
'rounded-lg border border-black p-4 text-center'
)}
>
<div className='row-span-3 m-auto'>
<InformationCircleIcon width={25} height={25} />
</div>
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
{studentDeck.deck.name}
</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
به زودی
</div>
</div>
</div>
);
}
return (
<div key={studentDeck.id} className='mb-1'>
<div
className={classNames(
'grid grid-flow-col grid-cols-9 sm:grid-flow-row ',
`bg-${this.getStudentDeckColor(studentDeck)}-100`,
{ 'rounded-b-lg': !this.isSelectedStudentDeck(studentDeck.id) },
'rounded-t-lg border border-black p-4 text-center'
)}
onClick={() => this.handleSelectStudentDeck(studentDeck)}
>
<div className='row-span-3 m-auto'>
{this.isSelectedStudentDeck(studentDeck.id) ? (
<PlusCircleIcon width={25} height={25} />
) : (
<MinusCircleIcon width={25} height={25} />
)}
</div>
<div className='col-span-8 m-auto font-bold sm:col-span-3'>
{studentDeck.deck.name}
</div>
<div className='col-span-8 my-2 flex justify-center gap-2 sm:col-span-4 sm:my-auto'>
<Badge text='مروری' number={studentDeck.reviewCardCount} color='red' />
<Badge text='جدید' number={studentDeck.newCardCount} color='yellow' />
</div>
{!this.props.rateType && (
<div className='row-span-3 m-auto font-bold'>
<Button
outline
circular
size='xs'
color='orange'
disabled={studentDeck.deck.cardCount === studentDeck.newCardCount}
onClick={e => {
e.stopPropagation();
this.handleRefreshSelected({
id: studentDeck.deck.id,
type: 'deck',
name: studentDeck.deck.name
});
}}
>
<RefreshIcon width={20} height={20} />
</Button>
</div>
)}
</div>
{this.isSelectedStudentDeck(studentDeck.id) && (
<StudentDeckContainer
studentDeckId={studentDeck.id}
onSelectChapter={this.handleSelectChapter}
onRefreshSelected={this.handleRefreshSelected}
/>
)}
</div>
);
})}
</div>
{this.state.selectedChapter && (
<ChapterModal
chapter={this.state.selectedChapter}
lessonName={this.state.selectedStudentDeck?.deck.lesson.name}
priority={this.props.priority}
rateType={this.props.rateType}
onClose={() => this.handleSelectChapter(null)}
/>
)}
{this.state.selectedRefreshItem && (
<ResetModal
itemType={this.state.selectedRefreshItem.type}
itemId={this.state.selectedRefreshItem.id}
itemName={this.state.selectedRefreshItem.name}
onClose={() => this.handleRefreshSelected(null)}
/>
)}
</Fragment>
);
}
}
StudentDecks.propTypes = {
priority: PropTypes.string,
rateType: PropTypes.string,
studentDecks: PropTypes.array.isRequired,
history: PropTypes.object.isRequired
};
export default withRouter(StudentDecks);

View File

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

View File

@@ -0,0 +1,38 @@
import { useLocation } from 'react-router-dom';
import ButtonGroup from '../../shared/buttons/button-group';
import NavLink from '../../shared/buttons/nav-link';
function PeriorityTab() {
const location = useLocation();
const urlSearchParams = new URLSearchParams(location.search);
const currentPriority = urlSearchParams.get('priority');
function getLinkColor(priority) {
return currentPriority === priority ? 'rose' : 'gray';
}
function getNextURL(priority) {
if (priority) {
urlSearchParams.set('priority', priority);
} else {
urlSearchParams.delete('priority');
}
let url = '/decks';
const urlSearchParamsSTR = urlSearchParams.toString();
if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`;
return url;
}
return (
<ButtonGroup className='mx-auto my-3 w-min'>
<NavLink to={getNextURL('high')} size='xl' color={getLinkColor('high')}>
ضروری
</NavLink>
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
همه
</NavLink>
</ButtonGroup>
);
}
export default PeriorityTab;

View File

@@ -0,0 +1,41 @@
import { useLocation } from 'react-router-dom';
import ButtonGroup from '../../shared/buttons/button-group';
import NavLink from '../../shared/buttons/nav-link';
function RateTypeTab() {
const location = useLocation();
const urlSearchParams = new URLSearchParams(location.search);
const currentRateType = urlSearchParams.get('rate-type');
function getLinkColor(rateType) {
return currentRateType === rateType ? 'blue' : 'gray';
}
function getNextURL(rateType) {
if (rateType) {
urlSearchParams.set('rate-type', rateType);
} else {
urlSearchParams.delete('rate-type');
}
let url = '/decks';
const urlSearchParamsSTR = urlSearchParams.toString();
if (urlSearchParamsSTR) url += `?${urlSearchParamsSTR}`;
return url;
}
return (
<ButtonGroup className='mx-auto my-3 w-min'>
<NavLink to={getNextURL('starred')} size='xl' color={getLinkColor('starred')}>
ستاره دار
</NavLink>
<NavLink to={getNextURL('active')} size='xl' color={getLinkColor('active')}>
فعال
</NavLink>
<NavLink to={getNextURL(null)} size='xl' color={getLinkColor(null)}>
همه
</NavLink>
</ButtonGroup>
);
}
export default RateTypeTab;

View File

@@ -0,0 +1,18 @@
export function getStudentDeckComparator(priority = null) {
return function compareStudentDeck(sd1, sd2) {
return (
(priority && sd2.deck.prioritized - sd1.deck.prioritized) ||
sd2.reviewCardCount - sd1.reviewCardCount ||
sd2.newCardCount - sd1.newCardCount ||
sd2.deck.cardCount - sd1.deck.cardCount
);
};
}
export function compareChapter(ch1, ch2) {
return (
ch2.reviewCardCount - ch1.reviewCardCount ||
ch2.newCardCount - ch1.newCardCount ||
ch2.cardCount - ch1.cardCount
);
}

View File

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

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import { useToasts } from 'react-toast-notifications';
import PropTypes from 'prop-types';
import MainLayout from '../layouts/main';
import MobileForm from './mobile-form';
import SMSKeyForm from '../shared/sms-key-form';
import PasswordForm from './password-form';
import Alert from '../shared/alert';
import {
checkSMSKeyRequest,
forgetPasswordRequest,
resetPasswordRequest
} from '../../redux/actions/user';
import { loginUser } from '../../redux/actions/auth';
import { selectIsAuthenticated } from '../../redux/selectors/auth';
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
function ForgetPasswordPage(props) {
const [step, setStep] = useState(0);
const [mobile, setMobile] = useState(null);
const [uid, setUID] = useState(null);
const [token, setToken] = useState(null);
const [error, setError] = useState(null);
const { addToast } = useToasts();
if (props.isAuthenticated) return <Redirect to={DEFAULT_DECKS_ROUTE} />;
function renderForm(step) {
if (step === MOBILE) {
return (
<MobileForm
onSubmit={async function (mobile) {
const {
payload: { uid }
} = await props.forgetPasswordRequest(mobile);
setMobile(mobile);
setUID(uid);
setStep(SMS_KEY);
}}
/>
);
}
if (step === SMS_KEY) {
return (
<SMSKeyForm
back
mobile={mobile}
onSubmit={async function (smsKey) {
const {
payload: { token }
} = await props.checkSMSKeyRequest(uid, smsKey);
setToken(token);
setStep(PASSWORD);
}}
onResend={async function () {
const {
payload: { uid }
} = await props.forgetPasswordRequest(mobile);
setUID(uid);
}}
onBack={setStep.bind(null, MOBILE)}
onError={setError}
/>
);
}
if (step === PASSWORD) {
return (
<PasswordForm
onSubmit={async function (password) {
const { error } = await props.resetPasswordRequest(uid, token, password);
if (!error) {
addToast('رمز عبور شما با موفقیت تغییر کرد.', { appearance: 'success' });
await props.loginUser(mobile, password);
setStep(MOBILE);
}
}}
/>
);
}
return null;
}
return (
<MainLayout loginRequired={false} consultPackageRequired={false} packageRequired={false}>
{error ? (
<Alert content={error} className='mt-3 mb-5' />
) : (
<div className='mx-auto w-full sm:w-4/5 lg:w-3/5'>{renderForm(step)}</div>
)}
</MainLayout>
);
}
const [MOBILE, SMS_KEY, PASSWORD] = [0, 1, 2];
ForgetPasswordPage.propTypes = {
isAuthenticated: PropTypes.bool.isRequired,
checkSMSKeyRequest: PropTypes.func.isRequired,
forgetPasswordRequest: PropTypes.func.isRequired,
resetPasswordRequest: PropTypes.func.isRequired,
loginUser: PropTypes.func.isRequired
};
function mapStateToProps(state) {
return { isAuthenticated: selectIsAuthenticated(state) };
}
export default connect(mapStateToProps, {
checkSMSKeyRequest,
forgetPasswordRequest,
resetPasswordRequest,
loginUser
})(ForgetPasswordPage);

View File

@@ -0,0 +1,58 @@
import { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Field, Form, Formik } from 'formik';
import Segment from '../shared/segment';
import Divider from '../shared/divider';
import Alert from '../shared/alert';
import Button from '../shared/buttons/button';
import FormInput from '../shared/forms/input';
import { mobile, required } from '../shared/forms/validations';
function MobileForm(props) {
async function submit({ mobile }, { setErrors }) {
try {
await props.onSubmit(mobile);
} catch (error) {
setErrors({ _error: error.data.detail || error.data });
}
}
return (
<Segment>
<Divider horizontal content='فرم فراموشی رمز عبور' />
<Formik initialValues={{ mobile: '' }} onSubmit={submit}>
{({ errors, dirty, isSubmitting }) => (
<Fragment>
{dirty && errors._error && <Alert content={errors._error} className='mt-3 mb-5' />}
<Form>
<Field
required
name='mobile'
label='شماره تلفن همراه'
type='tel'
placeholder='09121234567'
component={FormInput}
validate={[required, mobile]}
/>
<div className='text-medium my-4'>
شماره تلفن همراه وارد شده هنگام ثبت نام را وارد کنید.
</div>
<Button
type='submit'
disabled={!dirty || isSubmitting}
content='ادامه'
className='my-2 mr-auto'
/>
</Form>
</Fragment>
)}
</Formik>
</Segment>
);
}
MobileForm.propTypes = {
onSubmit: PropTypes.func.isRequired
};
export default MobileForm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { Fragment } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
import Button from '../shared/buttons/button';
import { archiveStudentCard } from '../../redux/actions/student-card';
import NavLink from '../shared/buttons/nav-link';
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
function Footer(props) {
const history = useHistory();
const { chapterId, type } = useParams();
let nextURL = `/cards/ch-${chapterId}/${type}`;
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
if (urlSearchParamsSTR) {
nextURL += `?${urlSearchParamsSTR}`;
}
const { disabledNext } = props;
return (
<Fragment>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
}
>
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
بازگشت
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</Button>
<NavLink
to={nextURL}
color='green'
textColor='white'
disabled={disabledNext}
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
</div>
</Fragment>
);
}
Footer.propTypes = {
disabledNext: PropTypes.bool.isRequired,
showArchive: PropTypes.bool.isRequired,
archiveStudentCard: PropTypes.func.isRequired,
isPermanentArchive: PropTypes.bool.isRequired
};
function mapStateToProps({ review }) {
return { isPermanentArchive: !!review.data.studentCard && !!review.data.studentCard.status };
}
export default connect(mapStateToProps, { archiveStudentCard })(Footer);

View File

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

View File

@@ -0,0 +1,29 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import Option from './option';
class OptionContainer extends Component {
render() {
const options = [...this.props.options, { id: 0, text: 'نمی دانم', isCorrect: false }];
return (
<div id='options' className='space-y-3 px-4'>
{options.map((option, index) => (
<Option
key={option.id}
option={option}
index={index}
isSelectedOption={this.props.selectedOptionId === option.id}
/>
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedOptionId: PropTypes.number.isRequired
};
export default OptionContainer;

View File

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

View File

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

View 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);

View File

@@ -0,0 +1,4 @@
export const DEFAULT_COLOR = 'gray';
export const CORRECT_COLOR = 'green';
export const WRONG_COLOR = 'red';
export const DONT_KNOW_COLOR = 'yellow';

View File

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

View 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);

View 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);

View 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;

View 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;

View 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);

View 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);

View 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;

View 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);

View 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;

View File

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

View 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;

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View 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;

View 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;

View File

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

View 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;

View 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;

View 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);

View 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
);

View 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;

View 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;

View 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);

View 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;

View 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;

View File

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

View 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;

View File

@@ -0,0 +1,24 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Alert(props) {
return (
<div
role='alert'
className={classNames(
'text-center text-lg font-medium',
'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;

View 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;

View 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;
}

View 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;

View File

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

View File

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

View File

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

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