From b65595da56919656567117afb735878185629fa5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 12 Apr 2022 03:29:48 +0300 Subject: [PATCH 01/34] setup create course page --- src/api/index.js | 2 +- src/routes/index.jsx | 3 +- src/screens/courses/courses-list/index.jsx | 10 ++++- src/screens/courses/create-course/config.js | 27 ++++++++++++ src/screens/courses/create-course/index.jsx | 44 +++++++++++++++++++ src/screens/courses/create-course/styles.scss | 31 +++++++++++++ src/screens/courses/index.js | 1 + 7 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/screens/courses/create-course/config.js create mode 100644 src/screens/courses/create-course/index.jsx create mode 100644 src/screens/courses/create-course/styles.scss diff --git a/src/api/index.js b/src/api/index.js index a705ae21f..91fa9e2bf 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -16,5 +16,5 @@ export const api = { profile, richText, community, - courses + courses, }; diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 67e2bcc68..e84206724 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -43,7 +43,7 @@ import CourseUsers from "../screens/courses/courseUsers/CourseUsers"; import MyCoursePage from "../screens/dashboard/coursePage/CoursePage"; import EditCollection from "../screens/courses/editCollection/EditCollection"; import CourseCollection from "../screens/courses/courseCollection/CourseCollection"; -import { CoursesListPage } from "../screens/courses"; +import { CoursesListPage, CreateCoursePage } from "../screens/courses"; // Messanger import Messenger from "../screens/messenger/Messenger"; @@ -161,6 +161,7 @@ export const Routes = () => { + { + const history = useHistory(); + const [sortBy, setSortBy] = useState(options[0]); const [selectedIndex, setSelectedIndex] = useState(0); @@ -43,7 +46,12 @@ export const CoursesListPage = () => { selectedIndex={selectedIndex} /> - + history.push("/courses/create")} + /> { + return ( + + {}} + initialValues={initialValues} + validationSchema={validationSchema} + > + {() => ( +
+
+
+ +
+ +
+ + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/screens/courses/create-course/styles.scss b/src/screens/courses/create-course/styles.scss new file mode 100644 index 000000000..3168d3380 --- /dev/null +++ b/src/screens/courses/create-course/styles.scss @@ -0,0 +1,31 @@ +@import "src/scss/mixins"; + +.create-course-page-container { + width: 100%; + + gap: 40px; + display: flex; + flex-direction: row; + + .left-block { + width: 344px; + height: 344px; + } + + .right-block { + flex-grow: 1; + + gap: 24px; + display: flex; + flex-direction: column; + } + + @include breakpoint("tablet", "max") { + flex-direction: column; + + .left-block { + width: 100%; + height: auto; + } + } +} diff --git a/src/screens/courses/index.js b/src/screens/courses/index.js index f10d0225a..9db33aab7 100644 --- a/src/screens/courses/index.js +++ b/src/screens/courses/index.js @@ -1 +1,2 @@ export { CoursesListPage } from "./courses-list"; +export { CreateCoursePage } from "./create-course"; From e40db72151649a6f9ae6d3b710adb59b05d1b8f5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 13 Apr 2022 01:53:41 +0300 Subject: [PATCH 02/34] implement content builder component --- package.json | 3 + .../containers/news/article-content/index.jsx | 8 +- .../news/article-editor/actions/index.jsx | 23 ------ .../containers/news/article-editor/config.js | 50 +------------ .../containers/news/article-editor/index.jsx | 65 +++------------- src/common/content-builder/actions/index.jsx | 43 +++++++++++ .../blocks}/index.jsx | 6 +- .../blocks}/styles.scss | 0 src/common/content-builder/config.js | 75 +++++++++++++++++++ src/common/content-builder/index.jsx | 70 +++++++++++++++++ src/common/content-builder/styles.scss | 12 +++ src/constants/enums.js | 2 +- src/screens/courses/create-course/config.js | 18 ++--- src/screens/courses/create-course/index.jsx | 36 +++++++-- src/screens/courses/create-course/styles.scss | 17 +++++ src/scss/_mixins.scss | 14 ++++ src/utils/article.js | 32 ++++---- src/utils/parsers/news.js | 8 +- 18 files changed, 313 insertions(+), 169 deletions(-) delete mode 100644 src/common/containers/news/article-editor/actions/index.jsx create mode 100644 src/common/content-builder/actions/index.jsx rename src/common/{containers/news/article-editor/fields => content-builder/blocks}/index.jsx (94%) rename src/common/{containers/news/article-editor/fields => content-builder/blocks}/styles.scss (100%) create mode 100644 src/common/content-builder/config.js create mode 100644 src/common/content-builder/index.jsx create mode 100644 src/common/content-builder/styles.scss diff --git a/package.json b/package.json index 209a881ee..1d0116fc6 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "planet-farm", "version": "0.1.0", "private": true, + "overrides": { + "react-error-overlay": "6.0.9" + }, "dependencies": { "@hookform/error-message": "^2.0.0", "@react-pdf/renderer": "^2.0.16", diff --git a/src/common/containers/news/article-content/index.jsx b/src/common/containers/news/article-content/index.jsx index b8ea6435a..8010914fd 100644 --- a/src/common/containers/news/article-content/index.jsx +++ b/src/common/containers/news/article-content/index.jsx @@ -2,7 +2,7 @@ import { useMemo, useEffect } from "react"; import ReactPlayer from "react-player"; import { useDeviceType } from "hooks"; -import { DeviceType, NewsContentType } from "constants/enums"; +import { DeviceType, ContentBuilderAction } from "constants/enums"; import { parseArticleImage, parseArticleVideo } from "utils/parsers/news"; import "./styles.scss"; @@ -91,7 +91,7 @@ export const ArticleContentList = ({ content = [] }) => {
{content.map((item, index) => { switch (item.type) { - case NewsContentType.Image: + case ContentBuilderAction.Image: return ( { /> ); - case NewsContentType.Text: + case ContentBuilderAction.Text: return ( { /> ); - case NewsContentType.Video: + case ContentBuilderAction.Video: return ( { - const data = [ - { icon: "file", title: "Text", onClick: onAddText }, - { icon: "camera", title: "Picture", onClick: onAddImage }, - { icon: "youtube", title: "Video", onClick: onAddVideo }, - ]; - - return ( -
- {data.map((item) => ( - - ))} -
- ); -}; diff --git a/src/common/containers/news/article-editor/config.js b/src/common/containers/news/article-editor/config.js index 271a80377..79b6884c8 100644 --- a/src/common/containers/news/article-editor/config.js +++ b/src/common/containers/news/article-editor/config.js @@ -1,6 +1,7 @@ import * as Yup from "yup"; -import { ArticleEditorType, NewsContentType } from "constants/enums"; +import { ArticleEditorType } from "constants/enums"; +import { contentBuilderValidationSchema } from "common/content-builder"; export const model = { title: { name: "title" }, @@ -24,52 +25,7 @@ export const validationSchema = Yup.object().shape({ [category.name]: dropdownSchema.required(), [readTime.name]: dropdownSchema.optional(), [community.name]: dropdownSchema.required(), - - [newsContent.name]: Yup.array() - .of( - Yup.object().shape({ - type: Yup.string().required(), - data: Yup.object() - .when("type", { - is: NewsContentType.Text, - then: Yup.object().shape({ - textHeading: Yup.string().optional(), - textDescription: Yup.string().required(), - }), - }) - .when("type", { - is: NewsContentType.Image, - then: Yup.object().shape({ - lessonImg: Yup.mixed().required(), - photoDescription: Yup.string().optional(), - }), - }) - .when("type", { - is: NewsContentType.Video, - then: Yup.object().shape( - { - videoTitle: Yup.string().optional(), - videoDescription: Yup.string().optional(), - videoResource: Yup.mixed().when("videoLink", (videoLink) => { - return videoLink - ? Yup.mixed().optional() - : Yup.mixed().required(); - }), - videoLink: Yup.string().when("videoResource", (videoFile) => { - return videoFile - ? Yup.string().optional() - : Yup.string() - .url("URL is not valid") - .required("Provide Link or choose file from device"); - }), - }, - ["videoResource", "videoLink"] - ), - }), - }) - ) - .min(1) - .required(), + [newsContent.name]: contentBuilderValidationSchema.min(1).required(), }); export const categoryOptions = [ diff --git a/src/common/containers/news/article-editor/index.jsx b/src/common/containers/news/article-editor/index.jsx index 8fcda7434..1deeb238f 100644 --- a/src/common/containers/news/article-editor/index.jsx +++ b/src/common/containers/news/article-editor/index.jsx @@ -1,21 +1,20 @@ import { useMemo, useEffect } from "react"; +import { Formik, Form } from "formik"; import { useAlert } from "react-alert"; -import { Formik, Form, FieldArray } from "formik"; import { DropdownField } from "common/dropdown"; import { TextAreaField } from "common/text-area"; import { DragDropZoneField } from "common/drag-drop-zone"; import { ActionButton } from "common/buttons/action-button"; +import { ContentBuilderField } from "common/content-builder"; import { api } from "api"; import { useStateIfMounted } from "hooks"; import { getErrorMessage } from "utils/error"; import { GET_NEWS } from "utils/urlConstants"; -import { NewsContentType } from "constants/enums"; +import { ContentBuilderAction } from "constants/enums"; -import { NewsActions } from "./actions"; import { getInitialValues } from "./helpers"; -import { TextFieldBlock, ImageFieldBlock, VideoFieldBlock } from "./fields"; import { model, readTimeOptions, @@ -84,56 +83,14 @@ export const ArticleEditor = ({ article, onSubmit, onPreview, type }) => { placeholder="Drag & Drop cover image in this area or" /> - - {({ push, remove }) => ( - <> - {values[model.newsContent.name].map((item, index) => { - const fieldName = `${model.newsContent.name}[${index}].data`; - - switch (item.type) { - case NewsContentType.Text: { - return ( - remove(index)} - key={`text-field-block-${index.toString()}`} - /> - ); - } - - case NewsContentType.Image: { - return ( - remove(index)} - key={`picture-field-block-${index.toString()}`} - /> - ); - } - - case NewsContentType.Video: { - return ( - remove(index)} - key={`video-field-block-${index.toString()}`} - /> - ); - } - - default: - return null; - } - })} - - push({ type: NewsContentType.Text })} - onAddVideo={() => push({ type: NewsContentType.Video })} - onAddImage={() => push({ type: NewsContentType.Image })} - /> - - )} - +
diff --git a/src/common/content-builder/actions/index.jsx b/src/common/content-builder/actions/index.jsx new file mode 100644 index 000000000..0b0bff611 --- /dev/null +++ b/src/common/content-builder/actions/index.jsx @@ -0,0 +1,43 @@ +import { ContentBuilderAction } from "constants/enums"; +import { IconButton } from "common/buttons/icon-button"; + +const buttonData = { + [ContentBuilderAction.Text]: { icon: "file", title: "Text" }, + [ContentBuilderAction.Image]: { icon: "camera", title: "Picture" }, + [ContentBuilderAction.Video]: { icon: "youtube", title: "Video" }, +}; + +export const Actions = ({ + onAddText, + onAddImage, + onAddVideo, + actions = [], +}) => { + return ( +
+ {actions.includes(ContentBuilderAction.Text) && ( + + )} + + {actions.includes(ContentBuilderAction.Image) && ( + + )} + + {actions.includes(ContentBuilderAction.Video) && ( + + )} +
+ ); +}; diff --git a/src/common/containers/news/article-editor/fields/index.jsx b/src/common/content-builder/blocks/index.jsx similarity index 94% rename from src/common/containers/news/article-editor/fields/index.jsx rename to src/common/content-builder/blocks/index.jsx index c55d54375..d635cceeb 100644 --- a/src/common/containers/news/article-editor/fields/index.jsx +++ b/src/common/content-builder/blocks/index.jsx @@ -31,7 +31,7 @@ const FieldBlock = ({ title, onRemove, children }) => { ); }; -export const TextFieldBlock = ({ name, onRemove }) => { +export const Text = ({ name, onRemove }) => { return ( { ); }; -export const ImageFieldBlock = ({ name, onRemove }) => { +export const Image = ({ name, onRemove }) => { return ( { ); }; -export const VideoFieldBlock = ({ name, onRemove }) => { +export const Video = ({ name, onRemove }) => { const [field] = useField(name); const { videoResource, videoLink } = field?.value || {}; diff --git a/src/common/containers/news/article-editor/fields/styles.scss b/src/common/content-builder/blocks/styles.scss similarity index 100% rename from src/common/containers/news/article-editor/fields/styles.scss rename to src/common/content-builder/blocks/styles.scss diff --git a/src/common/content-builder/config.js b/src/common/content-builder/config.js new file mode 100644 index 000000000..0bc23598b --- /dev/null +++ b/src/common/content-builder/config.js @@ -0,0 +1,75 @@ +import * as Yup from "yup"; + +import { ContentBuilderAction } from "constants/enums"; + +const model = { + type: { name: "type" }, + + text: { + heading: { name: "textHeading" }, + description: { name: "textDescription" }, + }, + + image: { + file: { name: "lessonImg" }, + description: { name: "photoDescription" }, + }, + + video: { + title: { name: "videoTitle" }, + description: { name: "videoDescription" }, + file: { name: "videoResource" }, + link: { name: "videoLink" }, + }, +}; + +const textBlockSchema = Yup.object().shape({ + [model.text.heading.name]: Yup.string().optional(), + [model.text.description.name]: Yup.string().required(), +}); + +const imageBlockSchema = Yup.object().shape({ + [model.image.file.name]: Yup.mixed().required(), + [model.image.description.name]: Yup.string().optional(), +}); + +const videoResourceSchema = Yup.mixed().when(model.video.link.name, (link) => { + return link ? Yup.mixed().optional() : Yup.mixed().required(); +}); + +const videoLinkSchema = Yup.string().when(model.video.file.name, (file) => { + return file + ? Yup.string().optional() + : Yup.string() + .url("URL is not valid") + .required("Provide Link or choose file from device"); +}); + +const videoBlockSchema = Yup.object().shape( + { + [model.video.title.name]: Yup.string().optional(), + [model.video.description.name]: Yup.string().optional(), + [model.video.file.name]: videoResourceSchema, + [model.video.link.name]: videoLinkSchema, + }, + [model.video.link.name, model.video.file.name] +); + +export const contentBuilderValidationSchema = Yup.array().of( + Yup.object().shape({ + [model.type.name]: Yup.string().required(), + data: Yup.object() + .when(model.type.name, { + is: ContentBuilderAction.Text, + then: textBlockSchema, + }) + .when(model.type.name, { + is: ContentBuilderAction.Image, + then: imageBlockSchema, + }) + .when(model.type.name, { + is: ContentBuilderAction.Video, + then: videoBlockSchema, + }), + }) +); diff --git a/src/common/content-builder/index.jsx b/src/common/content-builder/index.jsx new file mode 100644 index 000000000..03e172e79 --- /dev/null +++ b/src/common/content-builder/index.jsx @@ -0,0 +1,70 @@ +import { FieldArray, useField } from "formik"; + +import { ContentBuilderAction } from "constants/enums"; + +import * as Blocks from "./blocks"; +import { Actions } from "./actions"; + +import "./styles.scss"; + +const ContentBuilderField = ({ actions = [], name, label }) => { + const [field] = useField(name); + + if (!name) return null; + + return ( + + {({ push, remove }) => ( +
+ {label &&

{label}

} + + {field.value.map((item, index) => { + const fieldName = `${name}[${index}].data`; + + switch (item.type) { + case ContentBuilderAction.Text: + return ( + remove(index)} + key={`text-field-block-${index.toString()}`} + /> + ); + + case ContentBuilderAction.Image: + return ( + remove(index)} + key={`picture-field-block-${index.toString()}`} + /> + ); + + case ContentBuilderAction.Video: + return ( + remove(index)} + key={`video-field-block-${index.toString()}`} + /> + ); + + default: + return null; + } + })} + + push({ type: ContentBuilderAction.Text })} + onAddVideo={() => push({ type: ContentBuilderAction.Video })} + onAddImage={() => push({ type: ContentBuilderAction.Image })} + /> +
+ )} +
+ ); +}; + +export { ContentBuilderField }; +export { contentBuilderValidationSchema } from "./config"; diff --git a/src/common/content-builder/styles.scss b/src/common/content-builder/styles.scss new file mode 100644 index 000000000..3548da258 --- /dev/null +++ b/src/common/content-builder/styles.scss @@ -0,0 +1,12 @@ +@import "src/scss/mixins"; + +.content-builder-container { + gap: 24px; + width: 100%; + @include flexColumn(stretch, flex-start); + + .actions-container { + gap: 48px; + @include flexRow(center, center); + } +} diff --git a/src/constants/enums.js b/src/constants/enums.js index 1ad0bc645..004e3e1bb 100644 --- a/src/constants/enums.js +++ b/src/constants/enums.js @@ -11,7 +11,7 @@ export const DeviceType = { Desktop: "Desktop", }; -export const NewsContentType = { +export const ContentBuilderAction = { Text: "Text", Video: "Video", Image: "Image", diff --git a/src/screens/courses/create-course/config.js b/src/screens/courses/create-course/config.js index 4145553fa..5cc172aa7 100644 --- a/src/screens/courses/create-course/config.js +++ b/src/screens/courses/create-course/config.js @@ -1,27 +1,23 @@ import * as Yup from "yup"; +import { contentBuilderValidationSchema } from "common/content-builder"; export const model = { - avatar: { - name: "avatar", - }, - - title: { - name: "title", - }, - - price: { - name: "price", - }, + avatar: { name: "avatar" }, + title: { name: "title" }, + price: { name: "price" }, + content: { name: "content" }, }; export const validationSchema = Yup.object().shape({ [model.avatar.name]: Yup.mixed().required(), [model.title.name]: Yup.string().required(), [model.price.name]: Yup.string().required(), + [model.content.name]: contentBuilderValidationSchema.min(1).required(), }); export const initialValues = { [model.avatar.name]: null, [model.title.name]: "", [model.price.name]: "", + [model.content.name]: [], }; diff --git a/src/screens/courses/create-course/index.jsx b/src/screens/courses/create-course/index.jsx index ee22742c4..273e16a6d 100644 --- a/src/screens/courses/create-course/index.jsx +++ b/src/screens/courses/create-course/index.jsx @@ -1,40 +1,64 @@ import { Formik, Form } from "formik"; +import { useHistory } from "react-router-dom"; import { InputField } from "common/input"; import { TextAreaField } from "common/text-area"; import { DashboardLayout } from "layout/dashboard"; import { DragDropZoneField } from "common/drag-drop-zone"; +import { ContentBuilderField } from "common/content-builder"; +import { ActionButton } from "common/buttons/action-button"; import { validationSchema, initialValues, model } from "./config"; import "./styles.scss"; export const CreateCoursePage = () => { + const history = useHistory(); + return ( {}} + validateOnBlur={false} + validateOnChange={false} initialValues={initialValues} validationSchema={validationSchema} > {() => ( -
-
-
- -
+ +
+ +
-
+
+
+
+ + + +
+ history.goBack()} + /> + + +
)} diff --git a/src/screens/courses/create-course/styles.scss b/src/screens/courses/create-course/styles.scss index 3168d3380..c37b44edc 100644 --- a/src/screens/courses/create-course/styles.scss +++ b/src/screens/courses/create-course/styles.scss @@ -18,6 +18,18 @@ gap: 24px; display: flex; flex-direction: column; + + .section-block { + gap: 24px; + @include flexColumn(center, flex-start); + } + + .buttons-section { + @include flexRow(center, space-between); + button { + width: 152px; + } + } } @include breakpoint("tablet", "max") { @@ -27,5 +39,10 @@ width: 100%; height: auto; } + + .right-block { + gap: 40px; + @include flexColumn(center, flex-start); + } } } diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 62cb744f6..53763d636 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -94,3 +94,17 @@ -webkit-line-clamp: $number; -webkit-box-orient: vertical; } + +@mixin flexColumn($alignItems, $justifyContent) { + display: flex; + flex-direction: column; + align-items: $alignItems; + justify-content: $justifyContent; +} + +@mixin flexRow($alignItems, $justifyContent) { + display: flex; + flex-direction: row; + align-items: $alignItems; + justify-content: $justifyContent; +} diff --git a/src/utils/article.js b/src/utils/article.js index 508b609ba..8d4d54d38 100644 --- a/src/utils/article.js +++ b/src/utils/article.js @@ -1,6 +1,6 @@ import { api } from "api"; -import { NewsContentType } from "constants/enums"; import { isFileInstanse } from "utils/parsers/file"; +import { ContentBuilderAction } from "constants/enums"; const needToUpdate = ({ oldItem, newItem }) => { if (oldItem.data?.order !== newItem.data?.order) { @@ -8,14 +8,14 @@ const needToUpdate = ({ oldItem, newItem }) => { } switch (newItem.type) { - case NewsContentType.Text: { + case ContentBuilderAction.Text: { return ( oldItem.data?.textHeading !== newItem.data?.textHeading || oldItem.data?.textDescription !== newItem.data?.textDescription ); } - case NewsContentType.Image: { + case ContentBuilderAction.Image: { const { lessonImg, photoDescription } = newItem.data || {}; if (lessonImg && isFileInstanse(lessonImg)) { @@ -29,7 +29,7 @@ const needToUpdate = ({ oldItem, newItem }) => { return false; } - case NewsContentType.Video: { + case ContentBuilderAction.Video: { const { videoResource, videoLink, videoTitle, videoDescription } = newItem.data || {}; @@ -125,27 +125,27 @@ export const getPromises = ({ }); const payload = { - [NewsContentType.Text]: createTextPayload, - [NewsContentType.Image]: createImagePayload, - [NewsContentType.Video]: createVideoPayload, + [ContentBuilderAction.Text]: createTextPayload, + [ContentBuilderAction.Image]: createImagePayload, + [ContentBuilderAction.Video]: createVideoPayload, }; const deleteRequest = { - [NewsContentType.Text]: api.news.deleteTextBlock, - [NewsContentType.Image]: api.news.deleteImageBlock, - [NewsContentType.Video]: api.news.deleteVideoBlock, + [ContentBuilderAction.Text]: api.news.deleteTextBlock, + [ContentBuilderAction.Image]: api.news.deleteImageBlock, + [ContentBuilderAction.Video]: api.news.deleteVideoBlock, }; const editRequest = { - [NewsContentType.Text]: api.news.updateTextBlock, - [NewsContentType.Image]: api.news.updateImageBlock, - [NewsContentType.Video]: api.news.updateVideoBlock, + [ContentBuilderAction.Text]: api.news.updateTextBlock, + [ContentBuilderAction.Image]: api.news.updateImageBlock, + [ContentBuilderAction.Video]: api.news.updateVideoBlock, }; const createRequest = { - [NewsContentType.Text]: api.news.createTextBlock, - [NewsContentType.Image]: api.news.createImageBlock, - [NewsContentType.Video]: api.news.createVideoBlock, + [ContentBuilderAction.Text]: api.news.createTextBlock, + [ContentBuilderAction.Image]: api.news.createImageBlock, + [ContentBuilderAction.Video]: api.news.createVideoBlock, }; editList.forEach((item) => { diff --git a/src/utils/parsers/news.js b/src/utils/parsers/news.js index 4b057860f..aa691c84f 100644 --- a/src/utils/parsers/news.js +++ b/src/utils/parsers/news.js @@ -1,5 +1,5 @@ /* eslint-disable no-underscore-dangle */ -import { NewsContentType } from "constants/enums"; +import { ContentBuilderAction } from "constants/enums"; import { GET_NEWS, GET_VIDEO, @@ -61,15 +61,15 @@ export const parseArticleContent = (article) => { } = item; if (textDescription || textHeading) { - return { ...item, type: NewsContentType.Text }; + return { ...item, type: ContentBuilderAction.Text }; } if (lessonImg) { - return { ...item, type: NewsContentType.Image }; + return { ...item, type: ContentBuilderAction.Image }; } if (videoResource || videoLink) { - return { ...item, type: NewsContentType.Video }; + return { ...item, type: ContentBuilderAction.Video }; } return item; From 6e4760e539457d25853964be1623b1be3dd408fe Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 13 Apr 2022 02:58:54 +0300 Subject: [PATCH 03/34] implement currency input component --- package.json | 1 + src/common/input/index.jsx | 26 ++-------- src/common/input/input-component/index.jsx | 49 +++++++++++++++++++ src/screens/courses/create-course/index.jsx | 1 + src/screens/courses/create-course/styles.scss | 2 +- 5 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 src/common/input/input-component/index.jsx diff --git a/package.json b/package.json index 1d0116fc6..55ddd5642 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "parcel": "^2.0.0-rc.0", "react": "^17.0.2", "react-alert": "^7.0.3", + "react-currency-input-field": "^3.6.4", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", "react-dom": "^17.0.2", diff --git a/src/common/input/index.jsx b/src/common/input/index.jsx index c9fd10a22..38f9505d5 100644 --- a/src/common/input/index.jsx +++ b/src/common/input/index.jsx @@ -1,34 +1,14 @@ import React, { useState, useMemo } from "react"; import cx from "classnames"; import { useField } from "formik"; -import InputMask from "react-input-mask"; import { Icon } from "common/icon"; -import { DateInput } from "./date-input"; +import { InputComponent } from "./input-component"; import { FloatingPlaceholder } from "./placeholder"; import "./styles.scss"; -const InputWithType = ({ type, onBlur, onFocus, ...props }) => { - if (type === "date") { - return ; - } - - if (type === "tel") { - return ( - - ); - } - - return ; -}; - export const Input = ({ icon, name, @@ -64,13 +44,13 @@ export const Input = ({ {icon && }
- onChange(event.target.value)} + onChange={onChange} /> {withFloatingPlaceholder && ( diff --git a/src/common/input/input-component/index.jsx b/src/common/input/input-component/index.jsx new file mode 100644 index 000000000..407dc65fc --- /dev/null +++ b/src/common/input/input-component/index.jsx @@ -0,0 +1,49 @@ +import InputMask from "react-input-mask"; +import CurrencyInput from "react-currency-input-field"; + +import { DateInput } from "../date-input"; + +export const InputComponent = ({ + type, + onBlur, + onFocus, + onChange, + ...props +}) => { + if (type === "date") { + return ; + } + + if (type === "tel") { + return ( + onChange(event.target.value)} + {...props} + /> + ); + } + + if (type === "currency") { + return ( + onChange(value)} + {...props} + /> + ); + } + + return ( + onChange(event.target.value)} + {...props} + /> + ); +}; diff --git a/src/screens/courses/create-course/index.jsx b/src/screens/courses/create-course/index.jsx index 273e16a6d..dce03a950 100644 --- a/src/screens/courses/create-course/index.jsx +++ b/src/screens/courses/create-course/index.jsx @@ -39,6 +39,7 @@ export const CreateCoursePage = () => { /> diff --git a/src/screens/courses/create-course/styles.scss b/src/screens/courses/create-course/styles.scss index c37b44edc..f85f9c267 100644 --- a/src/screens/courses/create-course/styles.scss +++ b/src/screens/courses/create-course/styles.scss @@ -15,7 +15,7 @@ .right-block { flex-grow: 1; - gap: 24px; + gap: 40px; display: flex; flex-direction: column; From 793ae5e9f934b4d9e11fbc6aac2e329c78753094 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 14 Apr 2022 05:53:00 +0300 Subject: [PATCH 04/34] fix breakpoints --- src/scss/_mixins.scss | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 53763d636..a1a514149 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -3,10 +3,7 @@ @mixin breakpoint($point, $sizeType: "minMax") { @if $sizeType == "minMax" { @if $point == "mobile" { - @media (orientation: portrait) and (min-width: 0) and (max-width: $media-mobile-max-width) { - @content; - } - @media (orientation: landscape) and (min-width: 0) and (max-width: $media-tablet-max-width) { + @media (min-width: 0) and (max-width: $media-mobile-max-width) { @content; } } @else if $point == "tablet" { @@ -24,10 +21,7 @@ } } @else if $sizeType == "max" { @if $point == "mobile" { - @media (orientation: portrait) and (max-width: $media-mobile-max-width) { - @content; - } - @media (orientation: landscape) and (max-width: $media-tablet-max-width) { + @media (max-width: $media-mobile-max-width) { @content; } } @else if $point == "tablet" { From 53bed132216d13d7c4f99b061cc20449b15b235c Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 15 Apr 2022 06:58:30 +0300 Subject: [PATCH 05/34] implement switch component --- src/common/switch/_mixins.scss | 36 ++++++++++++++++ src/common/switch/index.jsx | 42 ++++++++++++++++++ src/common/switch/styles.scss | 43 +++++++++++++++++++ src/screens/courses/create-course/index.jsx | 12 +++++- src/screens/courses/create-course/styles.scss | 27 +++++++++++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/common/switch/_mixins.scss create mode 100644 src/common/switch/index.jsx create mode 100644 src/common/switch/styles.scss diff --git a/src/common/switch/_mixins.scss b/src/common/switch/_mixins.scss new file mode 100644 index 000000000..ea0f95eea --- /dev/null +++ b/src/common/switch/_mixins.scss @@ -0,0 +1,36 @@ +@mixin switch-off { + background-color: rgba(238, 239, 239, 0.1); + + .switch { + left: 4px; + background-color: #676969; + box-shadow: 2px 0px 4px rgba(0, 0, 0, 0.32), + inset -2px 0px 4px rgba(0, 0, 0, 0.08); + } + + &:hover { + background-color: #27332f; + .switch { + left: 50%; + transform: translateX(-50%); + } + } +} + +@mixin switch-on { + background-color: #009466; + + .switch { + left: 50%; + background-color: #eeefef; + box-shadow: -2px 0px 4px rgba(31, 68, 49, 0.4), + inset -2px 0px 4px rgba(0, 0, 0, 0.08); + } + + &:hover { + .switch { + left: 50%; + transform: translateX(-50%); + } + } +} diff --git a/src/common/switch/index.jsx b/src/common/switch/index.jsx new file mode 100644 index 000000000..a5d3d960f --- /dev/null +++ b/src/common/switch/index.jsx @@ -0,0 +1,42 @@ +import "./styles.scss"; + +const LabelPosition = { + Left: "left", + Right: "right", +}; + +export const Switch = ({ + label, + onChangeValue, + value = false, + name = "switch", + disabled = false, + labelPosition = LabelPosition.Left, +}) => { + const handleClick = () => { + if (!disabled && onChangeValue) { + onChangeValue(!value); + } + }; + + return ( +
+ {label && labelPosition === LabelPosition.Left &&

{label}

} + + + +
+
+
+ + {label && labelPosition === LabelPosition.Right &&

{label}

} +
+ ); +}; diff --git a/src/common/switch/styles.scss b/src/common/switch/styles.scss new file mode 100644 index 000000000..4e0cefe70 --- /dev/null +++ b/src/common/switch/styles.scss @@ -0,0 +1,43 @@ +@import "./mixins"; + +.switch-container { + gap: 16px; + display: flex; + flex-direction: row; + align-items: center; + + .switch-input { + display: none; + } + + .switch-checkbox-container { + width: 40px; + height: 24px; + cursor: pointer; + user-select: none; + position: relative; + border-radius: 18px; + + .switch { + width: 16px; + height: 16px; + border-radius: 16px; + transition: all 0.1s linear; + + top: 4px; + position: absolute; + } + } + + .switch-input:not(:checked) + .switch-checkbox-container { + @include switch-off; + } + + .switch-input:checked + .switch-checkbox-container { + @include switch-on; + } + + .switch-input:disabled + .switch-checkbox-container { + cursor: default; + } +} diff --git a/src/screens/courses/create-course/index.jsx b/src/screens/courses/create-course/index.jsx index dce03a950..59149320e 100644 --- a/src/screens/courses/create-course/index.jsx +++ b/src/screens/courses/create-course/index.jsx @@ -1,17 +1,20 @@ import { Formik, Form } from "formik"; import { useHistory } from "react-router-dom"; +import { Switch } from "common/switch"; import { InputField } from "common/input"; import { TextAreaField } from "common/text-area"; import { DashboardLayout } from "layout/dashboard"; import { DragDropZoneField } from "common/drag-drop-zone"; -import { ContentBuilderField } from "common/content-builder"; import { ActionButton } from "common/buttons/action-button"; +import { ContentBuilderField } from "common/content-builder"; import { validationSchema, initialValues, model } from "./config"; import "./styles.scss"; +// TODO: Implement custom switch; + export const CreateCoursePage = () => { const history = useHistory(); @@ -27,7 +30,10 @@ export const CreateCoursePage = () => { {() => (
- +
@@ -38,6 +44,8 @@ export const CreateCoursePage = () => { name={model.title.name} /> + + Date: Tue, 19 Apr 2022 00:48:29 +0300 Subject: [PATCH 06/34] implement switch formik field --- src/common/switch/index.jsx | 43 ++++++++++++++----- src/screens/courses/create-course/config.js | 3 ++ src/screens/courses/create-course/index.jsx | 17 +++++--- src/screens/courses/create-course/styles.scss | 5 +++ 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/common/switch/index.jsx b/src/common/switch/index.jsx index a5d3d960f..b2aeff15f 100644 --- a/src/common/switch/index.jsx +++ b/src/common/switch/index.jsx @@ -1,34 +1,33 @@ -import "./styles.scss"; +import { useField } from "formik"; -const LabelPosition = { - Left: "left", - Right: "right", -}; +import "./styles.scss"; export const Switch = ({ - label, + leftLabel, + rightLabel, onChangeValue, value = false, name = "switch", disabled = false, - labelPosition = LabelPosition.Left, }) => { const handleClick = () => { if (!disabled && onChangeValue) { - onChangeValue(!value); + const currentValue = !!value; + onChangeValue(!currentValue); } }; return (
- {label && labelPosition === LabelPosition.Left &&

{label}

} + {leftLabel &&

{leftLabel}

} @@ -36,7 +35,29 @@ export const Switch = ({
- {label && labelPosition === LabelPosition.Right &&

{label}

} + {rightLabel &&

{rightLabel}

}
); }; + +export const SwitchField = ({ + name, + disabled, + leftLabel, + rightLabel, + labelPosition, +}) => { + const [field, meta, helpers] = useField(name); + + return ( + + ); +}; diff --git a/src/screens/courses/create-course/config.js b/src/screens/courses/create-course/config.js index 5cc172aa7..68bab0bf2 100644 --- a/src/screens/courses/create-course/config.js +++ b/src/screens/courses/create-course/config.js @@ -6,12 +6,14 @@ export const model = { title: { name: "title" }, price: { name: "price" }, content: { name: "content" }, + isPublished: { name: "isPublished" }, }; export const validationSchema = Yup.object().shape({ [model.avatar.name]: Yup.mixed().required(), [model.title.name]: Yup.string().required(), [model.price.name]: Yup.string().required(), + [model.isPublished.name]: Yup.boolean().required(), [model.content.name]: contentBuilderValidationSchema.min(1).required(), }); @@ -20,4 +22,5 @@ export const initialValues = { [model.title.name]: "", [model.price.name]: "", [model.content.name]: [], + [model.isPublished.name]: false, }; diff --git a/src/screens/courses/create-course/index.jsx b/src/screens/courses/create-course/index.jsx index 59149320e..090f5daf3 100644 --- a/src/screens/courses/create-course/index.jsx +++ b/src/screens/courses/create-course/index.jsx @@ -1,8 +1,8 @@ import { Formik, Form } from "formik"; import { useHistory } from "react-router-dom"; -import { Switch } from "common/switch"; import { InputField } from "common/input"; +import { SwitchField } from "common/switch"; import { TextAreaField } from "common/text-area"; import { DashboardLayout } from "layout/dashboard"; import { DragDropZoneField } from "common/drag-drop-zone"; @@ -13,16 +13,18 @@ import { validationSchema, initialValues, model } from "./config"; import "./styles.scss"; -// TODO: Implement custom switch; - export const CreateCoursePage = () => { const history = useHistory(); + const handleSubmit = (values) => { + console.log(values); + }; + return ( {}} validateOnBlur={false} + onSubmit={handleSubmit} validateOnChange={false} initialValues={initialValues} validationSchema={validationSchema} @@ -44,7 +46,12 @@ export const CreateCoursePage = () => { name={model.title.name} /> - +
+ +
Date: Tue, 19 Apr 2022 03:06:21 +0300 Subject: [PATCH 07/34] setup redux store for courses --- src/common/switch/index.jsx | 1 - src/hooks/courses/useCoursesList.js | 81 +------------------- src/screens/courses/create-course/index.jsx | 15 +++- src/store/courses/index.js | 3 + src/store/courses/selectors.js | 8 ++ src/store/courses/slice.js | 48 ++++++++++++ src/store/courses/thunks.js | 41 ++++++++++ src/store/index.js | 2 + src/utils/mocked.js | 83 +++++++++++++++++++++ 9 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 src/store/courses/index.js create mode 100644 src/store/courses/selectors.js create mode 100644 src/store/courses/slice.js create mode 100644 src/store/courses/thunks.js create mode 100644 src/utils/mocked.js diff --git a/src/common/switch/index.jsx b/src/common/switch/index.jsx index b2aeff15f..9d021f133 100644 --- a/src/common/switch/index.jsx +++ b/src/common/switch/index.jsx @@ -25,7 +25,6 @@ export const Switch = ({ id={name} name={name} type="checkbox" - checked={!!value} disabled={disabled} defaultChecked={!!value} className="switch-input" diff --git a/src/hooks/courses/useCoursesList.js b/src/hooks/courses/useCoursesList.js index 8373efe7a..2b6948e9c 100644 --- a/src/hooks/courses/useCoursesList.js +++ b/src/hooks/courses/useCoursesList.js @@ -4,87 +4,10 @@ import { useAlert } from "react-alert"; import { api } from "api"; import { SortOption } from "constants/enums"; +import { mockedCourses } from "utils/mocked"; import { getErrorMessage } from "utils/error"; import { useSearchBar } from "providers/search-bar"; -export const mockedAllCourses = [ - { - id: 0, - title: "A Fueling the ethanol industry", - price: 2333, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 4, - progress: 12, - members: 20, - }, - { - id: 1, - title: "B Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 5, - progress: 70, - members: 10, - }, - { - id: 2, - title: "C Fueling the ethanol industry", - price: 122, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 5, - progress: 40, - members: 50, - }, - { - id: 3, - title: "Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 1, - progress: 40, - members: 100, - }, - { - id: 4, - title: "Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 2, - progress: 45, - members: 0, - }, - { - id: 5, - title: "Fueling the ethanol industry", - price: 3400, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 3, - progress: 90, - members: 200, - }, -]; - const sortCoursesBy = ({ list = [], sortType }) => { return list.sort((a, b) => { switch (sortType) { @@ -135,7 +58,7 @@ export const useCoursesList = ({ filter, sort, withFakeData }) => { useEffect(() => { if (withFakeData) { - setCourses(sortCoursesBy({ list: mockedAllCourses, sortType: sort })); + setCourses(sortCoursesBy({ list: [...mockedCourses], sortType: sort })); } }, [withFakeData, sort]); diff --git a/src/screens/courses/create-course/index.jsx b/src/screens/courses/create-course/index.jsx index 090f5daf3..f42e9d8a5 100644 --- a/src/screens/courses/create-course/index.jsx +++ b/src/screens/courses/create-course/index.jsx @@ -1,4 +1,6 @@ import { Formik, Form } from "formik"; +import { useAlert } from "react-alert"; +import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; import { InputField } from "common/input"; @@ -9,15 +11,24 @@ import { DragDropZoneField } from "common/drag-drop-zone"; import { ActionButton } from "common/buttons/action-button"; import { ContentBuilderField } from "common/content-builder"; +import { createCourseThunk } from "store/courses"; + import { validationSchema, initialValues, model } from "./config"; import "./styles.scss"; export const CreateCoursePage = () => { + const alert = useAlert; const history = useHistory(); + const dispatch = useDispatch(); - const handleSubmit = (values) => { - console.log(values); + const handleSubmit = async (values) => { + try { + await createCourseThunk(values)(dispatch); + history.goBack(); + } catch (error) { + alert.error(error); + } }; return ( diff --git a/src/store/courses/index.js b/src/store/courses/index.js new file mode 100644 index 000000000..2ab40e374 --- /dev/null +++ b/src/store/courses/index.js @@ -0,0 +1,3 @@ +export * from "./slice"; +export * from "./thunks"; +export * from "./selectors"; diff --git a/src/store/courses/selectors.js b/src/store/courses/selectors.js new file mode 100644 index 000000000..8984a3ee4 --- /dev/null +++ b/src/store/courses/selectors.js @@ -0,0 +1,8 @@ +import { createSelector } from "reselect"; + +const selectStore = (store) => store.courses; + +export const selectCoursesList = createSelector( + [selectStore], + (store) => store.list +); diff --git a/src/store/courses/slice.js b/src/store/courses/slice.js new file mode 100644 index 000000000..1a259a3c4 --- /dev/null +++ b/src/store/courses/slice.js @@ -0,0 +1,48 @@ +import { createSlice } from "@reduxjs/toolkit"; + +import { mockedCourses } from "utils/mocked"; + +const initialState = { + list: mockedCourses, + selectedArticle: null, +}; + +const coursesSlice = createSlice({ + name: "courses", + initialState, + reducers: { + setCourses: (state, { payload }) => { + state.list = [...payload]; + }, + + createCourse: (state, { payload }) => { + state.list = [...state.list, payload]; + }, + + setSelectedCourse: (state, { payload }) => { + state.selectedArticle = payload; + }, + + removeSelectedCourse: (state) => { + state.selectedArticle = null; + }, + }, +}); + +const { + reducer: coursesReducer, + actions: { + setCourses, + createCourse, + setSelectedCourse, + removeSelectedCourse, + }, +} = coursesSlice; + +export { + coursesReducer, + setCourses, + createCourse, + setSelectedCourse, + removeSelectedCourse, +}; diff --git a/src/store/courses/thunks.js b/src/store/courses/thunks.js new file mode 100644 index 000000000..f6dfe7b45 --- /dev/null +++ b/src/store/courses/thunks.js @@ -0,0 +1,41 @@ +import { mockedCourses } from "utils/mocked"; + +import { createCourse, setCourses } from "./slice"; + +// const CourseFilterType = { +// Browse: "Browse", +// Active: "Active", +// Completed: "Completed", +// Archive: "Archive", +// MyCourses: "MyCourses", +// }; + +export const getCoursesThunk = () => async (dispatch) => { + try { + const courses = mockedCourses; + dispatch(setCourses(courses)); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +}; + +export const createCourseThunk = + ({ avatar, title, price, content, isPublished }) => + async (dispatch) => { + try { + const createdCourse = { + title, + price, + content, + isPublished, + createdAt: new Date(), + avatar: avatar ? URL.createObjectURL(avatar) : null, + }; + + dispatch(createCourse(createdCourse)); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } + }; diff --git a/src/store/index.js b/src/store/index.js index 2ab03d326..dd0c4af87 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -108,6 +108,7 @@ import { import { newsReducer } from "./news/slices"; import { userReducer } from "./user/slices"; +import { coursesReducer } from "./courses"; const reducer = combineReducers({ listEvents: eventListReducer, @@ -186,6 +187,7 @@ const reducer = combineReducers({ news: newsReducer, user: userReducer, + courses: coursesReducer, }); const userInfoFromStorage = window.localStorage.getItem("userInfo") diff --git a/src/utils/mocked.js b/src/utils/mocked.js new file mode 100644 index 000000000..0d8ff1770 --- /dev/null +++ b/src/utils/mocked.js @@ -0,0 +1,83 @@ +export const mockedCourses = [ + { + id: 0, + title: "A Fueling the ethanol industry", + price: 2333, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 4, + progress: 12, + members: 20, + createdAt: new Date(2021, 11, 17), + }, + { + id: 1, + title: "B Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 5, + progress: 70, + members: 10, + createdAt: new Date(2020, 5, 17), + }, + { + id: 2, + title: "C Fueling the ethanol industry", + price: 122, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 5, + progress: 40, + members: 50, + createdAt: new Date(2020, 11, 5), + }, + { + id: 3, + title: "Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 1, + progress: 40, + members: 100, + createdAt: new Date(2022, 4, 17), + }, + { + id: 4, + title: "Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 2, + progress: 45, + members: 0, + createdAt: new Date(2022, 11, 17), + }, + { + id: 5, + title: "Fueling the ethanol industry", + price: 3400, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 3, + progress: 90, + members: 200, + createdAt: new Date(2022, 11, 17), + }, +]; From 03dfa0da5765753a1991bf7830343661620ba479 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 19 Apr 2022 18:41:16 +0300 Subject: [PATCH 08/34] setup course page --- src/routes/index.jsx | 11 +++++++++-- src/screens/courses/course/index.jsx | 13 +++++++++++++ src/screens/courses/courses-list/index.jsx | 2 +- src/screens/courses/index.js | 1 + src/store/courses/selectors.js | 5 +++++ src/utils/mocked.js | 12 ++++++------ 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 src/screens/courses/course/index.jsx diff --git a/src/routes/index.jsx b/src/routes/index.jsx index e84206724..813a991a3 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -43,7 +43,11 @@ import CourseUsers from "../screens/courses/courseUsers/CourseUsers"; import MyCoursePage from "../screens/dashboard/coursePage/CoursePage"; import EditCollection from "../screens/courses/editCollection/EditCollection"; import CourseCollection from "../screens/courses/courseCollection/CourseCollection"; -import { CoursesListPage, CreateCoursePage } from "../screens/courses"; +import { + CoursePage, + CoursesListPage, + CreateCoursePage, +} from "../screens/courses"; // Messanger import Messenger from "../screens/messenger/Messenger"; @@ -130,6 +134,9 @@ export const Routes = () => { + + + {/* */} { - + { + const { id } = useParams(); + + const course = useSelector((state) => selectCurrentCourse(state, id)); + + return ; +}; diff --git a/src/screens/courses/courses-list/index.jsx b/src/screens/courses/courses-list/index.jsx index 693985ad9..8ce7e2db0 100644 --- a/src/screens/courses/courses-list/index.jsx +++ b/src/screens/courses/courses-list/index.jsx @@ -73,7 +73,7 @@ export const CoursesListPage = () => { description={item.description} key={`courses-list-item-${item.id}`} variant={CourseListItemVariants[selectedIndex]} - onClick={() => {}} + onClick={() => history.push(`/courses/${item.id}`)} ref={ index === courses.length - 1 ? (node) => setElementObserver(node) diff --git a/src/screens/courses/index.js b/src/screens/courses/index.js index 9db33aab7..b494c2898 100644 --- a/src/screens/courses/index.js +++ b/src/screens/courses/index.js @@ -1,2 +1,3 @@ +export { CoursePage } from "./course"; export { CoursesListPage } from "./courses-list"; export { CreateCoursePage } from "./create-course"; diff --git a/src/store/courses/selectors.js b/src/store/courses/selectors.js index 8984a3ee4..d6572c7df 100644 --- a/src/store/courses/selectors.js +++ b/src/store/courses/selectors.js @@ -6,3 +6,8 @@ export const selectCoursesList = createSelector( [selectStore], (store) => store.list ); + +export const selectCurrentCourse = createSelector( + [selectStore, (_, id) => id], + (store, id) => store.list.find((item) => item.id === id) +); diff --git a/src/utils/mocked.js b/src/utils/mocked.js index 0d8ff1770..f1d080629 100644 --- a/src/utils/mocked.js +++ b/src/utils/mocked.js @@ -1,6 +1,6 @@ export const mockedCourses = [ { - id: 0, + id: "0", title: "A Fueling the ethanol industry", price: 2333, description: @@ -14,7 +14,7 @@ export const mockedCourses = [ createdAt: new Date(2021, 11, 17), }, { - id: 1, + id: "1", title: "B Fueling the ethanol industry", price: 2599, description: @@ -27,7 +27,7 @@ export const mockedCourses = [ createdAt: new Date(2020, 5, 17), }, { - id: 2, + id: "2", title: "C Fueling the ethanol industry", price: 122, description: @@ -40,7 +40,7 @@ export const mockedCourses = [ createdAt: new Date(2020, 11, 5), }, { - id: 3, + id: "3", title: "Fueling the ethanol industry", price: 2599, description: @@ -54,7 +54,7 @@ export const mockedCourses = [ createdAt: new Date(2022, 4, 17), }, { - id: 4, + id: "4", title: "Fueling the ethanol industry", price: 2599, description: @@ -67,7 +67,7 @@ export const mockedCourses = [ createdAt: new Date(2022, 11, 17), }, { - id: 5, + id: "5", title: "Fueling the ethanol industry", price: 3400, description: From c19d3dec3c88ebc7439c91efa13f78c8c1e45c72 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 20 Apr 2022 00:28:05 +0300 Subject: [PATCH 09/34] implement responsive component, implement course main info --- package.json | 1 + src/common/responsive/index.jsx | 32 +++++++++++ src/constants/enums.js | 4 +- src/hooks/useResponsive.js | 36 +++++++++++++ src/screens/courses/course/index.jsx | 33 +++++++++++- .../courses/course/main-info/index.jsx | 49 +++++++++++++++++ .../courses/course/main-info/styles.scss | 46 ++++++++++++++++ src/screens/courses/course/styles.scss | 54 +++++++++++++++++++ src/scss/_colors.scss | 3 ++ src/scss/_variables.scss | 6 ++- 10 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 src/common/responsive/index.jsx create mode 100644 src/hooks/useResponsive.js create mode 100644 src/screens/courses/course/main-info/index.jsx create mode 100644 src/screens/courses/course/main-info/styles.scss create mode 100644 src/screens/courses/course/styles.scss create mode 100644 src/scss/_colors.scss diff --git a/package.json b/package.json index 55ddd5642..d14b5164b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-portal": "^4.2.1", "react-query": "^3.17.1", "react-redux": "^7.2.3", + "react-responsive": "^9.0.0-beta.6", "react-router-dom": "^5.3.0", "react-scripts": "4.0.3", "react-select": "^5.2.2", diff --git a/src/common/responsive/index.jsx b/src/common/responsive/index.jsx new file mode 100644 index 000000000..b79c2a480 --- /dev/null +++ b/src/common/responsive/index.jsx @@ -0,0 +1,32 @@ +import { + isLaptop, + isMobile, + isTablet, + isDesktop, + isLaptopUp, + isMobileUp, +} from "hooks/useResponsive"; + +export const Desktop = ({ children }) => { + return isDesktop() ? <>{children} : null; +}; + +export const Laptop = ({ children }) => { + return isLaptop() ? <>{children} : null; +}; + +export const Tablet = ({ children }) => { + return isTablet() ? <>{children} : null; +}; + +export const Mobile = ({ children }) => { + return isMobile() ? <>{children} : null; +}; + +export const MobileUp = ({ children }) => { + return isMobileUp() ? <>{children} : null; +}; + +export const LaptopUp = ({ children }) => { + return isLaptopUp() ? <>{children} : null; +}; diff --git a/src/constants/enums.js b/src/constants/enums.js index 004e3e1bb..96cbf9efa 100644 --- a/src/constants/enums.js +++ b/src/constants/enums.js @@ -1,6 +1,6 @@ export const DeviceMaxWidth = { - Mobile: 425, - Tablet: 845, + Mobile: 767, + Tablet: 1023, Laptop: 1440, }; diff --git a/src/hooks/useResponsive.js b/src/hooks/useResponsive.js new file mode 100644 index 000000000..a922bde62 --- /dev/null +++ b/src/hooks/useResponsive.js @@ -0,0 +1,36 @@ +import { useMediaQuery } from "react-responsive"; + +import { DeviceMaxWidth } from "constants/enums"; + +export const isDesktop = () => + useMediaQuery({ + minWidth: DeviceMaxWidth.Laptop + 1, + }); + +export const isLaptopUp = () => + useMediaQuery({ + minWidth: DeviceMaxWidth.Tablet + 1, + }); + +export const isLaptop = () => + useMediaQuery({ + minWidth: DeviceMaxWidth.Tablet + 1, + maxWidth: DeviceMaxWidth.Laptop, + }); + +export const isTablet = () => + useMediaQuery({ + minWidth: DeviceMaxWidth.Mobile + 1, + maxWidth: DeviceMaxWidth.Tablet, + }); + +export const isMobileUp = () => + useMediaQuery({ + minWidth: 0, + maxWidth: DeviceMaxWidth.Tablet, + }); + +export const isMobile = () => + useMediaQuery({ + maxWidth: DeviceMaxWidth.Mobile, + }); diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index f39eac51a..6aa7e9ee0 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -1,13 +1,44 @@ import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; +import { ActionButton } from "common/buttons/action-button"; + import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; +import { CourseMainInfo } from "./main-info"; + +import "./styles.scss"; + export const CoursePage = () => { const { id } = useParams(); const course = useSelector((state) => selectCurrentCourse(state, id)); - return ; + const handleBuyCourse = () => {}; + + return ( + +
+
+ + + +
+ +
+
+ + ); }; diff --git a/src/screens/courses/course/main-info/index.jsx b/src/screens/courses/course/main-info/index.jsx new file mode 100644 index 000000000..8d630b3c8 --- /dev/null +++ b/src/screens/courses/course/main-info/index.jsx @@ -0,0 +1,49 @@ +import { Avatar } from "common/avatar"; +import { StarsRating } from "common/stars-rating"; +import { MobileUp, LaptopUp } from "common/responsive"; + +import "./styles.scss"; + +export const CourseMainInfo = ({ avatar, title, price, members, rating }) => { + const coursePrice = price + ? `$${parseFloat(parseFloat(price) / 100).toFixed(2)}` + : "$00.00"; + + const courseMembers = `${members || 0} people tried`; + + return ( +
+ + +
+ +

{title}

+
+ + +

{title}

+
+ + +

{coursePrice}

+
+ + +

{coursePrice}

+
+ +
+ +
{courseMembers}
+
+ + +
{courseMembers}
+
+ + +
+
+
+ ); +}; diff --git a/src/screens/courses/course/main-info/styles.scss b/src/screens/courses/course/main-info/styles.scss new file mode 100644 index 000000000..987e6c72b --- /dev/null +++ b/src/screens/courses/course/main-info/styles.scss @@ -0,0 +1,46 @@ +@import "src/scss/mixins"; + +.course-main-info-container { + gap: 24px; + flex-grow: 1; + @include flexRow(flex-start, flex-start); + + .avatar-container { + width: 152px; + height: 152px; + border-radius: 152px; + } + + .info-column-container { + flex-grow: 1; + gap: 8px; + @include flexColumn(flex-start, space-between); + + .bottom-container { + gap: 8px; + width: 100%; + @include flexColumn(flex-start, flex-start); + } + } + + @include breakpoint("tablet", "max") { + gap: 16px; + width: 100%; + @include flexRow(center, flex-start); + + .avatar-container { + width: 64px; + height: 64px; + border-radius: 64px; + } + + .info-column-container { + gap: 8px; + @include flexColumn(flex-start, space-between); + + .bottom-container { + @include flexRow(center, space-between); + } + } + } +} diff --git a/src/screens/courses/course/styles.scss b/src/screens/courses/course/styles.scss new file mode 100644 index 000000000..dd1c7e16c --- /dev/null +++ b/src/screens/courses/course/styles.scss @@ -0,0 +1,54 @@ +@import "src/scss/mixins"; + +.current-course-page-container { + width: 100%; + background-color: transparent; + @include flexColumn(flex-start, flex-start); + + .header-container { + width: 100%; + gap: 40px; + @include flexRow(center, space-between); + + .info-container { + flex-grow: 1; + gap: 24px; + @include flexRow(flex-start, flex-start); + + .avatar-container { + width: 152px; + height: 152px; + border-radius: 152px; + } + + .info-column-container { + height: 152px; + @include flexColumn(flex-start, space-between); + } + } + + button { + width: 248px; + } + } + + @include breakpoint("tablet", "max") { + .header-container { + @include flexColumn(flex-start, center); + + .info-container { + @include flexRow(center, flex-start); + + .avatar-container { + width: 64px; + height: 64px; + border-radius: 64px; + } + } + + button { + width: 100%; + } + } + } +} diff --git a/src/scss/_colors.scss b/src/scss/_colors.scss new file mode 100644 index 000000000..c0bdd6c87 --- /dev/null +++ b/src/scss/_colors.scss @@ -0,0 +1,3 @@ +$color-green: #58bd88; +$color-green-hover: #009466; +$color-green-active: #007a54; diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 0f2e3f041..c39279f02 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -1,3 +1,4 @@ +// FONTS $font-regular: "IBM Plex Sans Regular", sans-serif; $font-semi-bold: "IBM Plex Sans Semibold", sans-serif; @@ -9,8 +10,9 @@ $font-body: 16px/24px $font-regular; $font-placeholder-bold: 14px/24px $font-semi-bold; $font-placeholder: 14px/24px $font-regular; -$media-mobile-max-width: 425px; -$media-tablet-max-width: 845px; +// MEDIA-QUERIES +$media-mobile-max-width: 767px; +$media-tablet-max-width: 1023px; $media-laptop-max-width: 1440px; $padding-desktop: 80px; From aba5b5303371bfd1574ee0836e74bcc4f5de6a26 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 20 Apr 2022 02:42:11 +0300 Subject: [PATCH 10/34] setup content list component --- .../containers/news/article-content/index.jsx | 5 ++- src/common/content/blocks/index.jsx | 44 +++++++++++++++++++ src/common/content/blocks/styles.scss | 34 ++++++++++++++ src/common/content/index.jsx | 36 +++++++++++++++ src/common/content/styles.scss | 8 ++++ src/screens/courses/course/index.jsx | 5 ++- src/screens/courses/course/styles.scss | 40 +++++------------ src/scss/_colors.scss | 40 +++++++++++++++++ src/scss/_mixins.scss | 5 ++- src/scss/_variables.scss | 4 ++ src/scss/styles.scss | 1 + src/utils/mocked.js | 42 +++++++++++++++++- 12 files changed, 231 insertions(+), 33 deletions(-) create mode 100644 src/common/content/blocks/index.jsx create mode 100644 src/common/content/blocks/styles.scss create mode 100644 src/common/content/index.jsx create mode 100644 src/common/content/styles.scss diff --git a/src/common/containers/news/article-content/index.jsx b/src/common/containers/news/article-content/index.jsx index 8010914fd..3b0d2d4fd 100644 --- a/src/common/containers/news/article-content/index.jsx +++ b/src/common/containers/news/article-content/index.jsx @@ -2,11 +2,14 @@ import { useMemo, useEffect } from "react"; import ReactPlayer from "react-player"; import { useDeviceType } from "hooks"; +import { isFileInstanse } from "utils/parsers/file"; import { DeviceType, ContentBuilderAction } from "constants/enums"; import { parseArticleImage, parseArticleVideo } from "utils/parsers/news"; import "./styles.scss"; -import { isFileInstanse } from "utils/parsers/file"; + +// TODO: Refactor; +// TODO: Use blocks in News flow from common/content; const Title = ({ isMobile, title }) => { if (!title) return null; diff --git a/src/common/content/blocks/index.jsx b/src/common/content/blocks/index.jsx new file mode 100644 index 000000000..36d2c5349 --- /dev/null +++ b/src/common/content/blocks/index.jsx @@ -0,0 +1,44 @@ +import { useEffect, useMemo } from "react"; + +import { MobileUp, LaptopUp } from "common/responsive"; + +import { isFileInstanse } from "utils/parsers/file"; + +import "./styles.scss"; + +export const TextBlock = ({ title, text }) => { + return ( +
+ {title &&

{title}

}
+ {title &&

{title}

}
+ {text &&
{text}
} +
+ ); +}; + +export const ImageBlock = ({ image, description }) => { + const imagePath = useMemo(() => { + if (isFileInstanse(image)) { + return URL.createObjectURL(image); + } + return image; + }, [image]); + + useEffect( + () => () => { + if (isFileInstanse(image)) { + URL.revokeObjectURL(image); + } + }, + [image] + ); + + return ( +
+
+ +
+ {description &&
{description}
} +
+ ); +}; diff --git a/src/common/content/blocks/styles.scss b/src/common/content/blocks/styles.scss new file mode 100644 index 000000000..6cb1b5f24 --- /dev/null +++ b/src/common/content/blocks/styles.scss @@ -0,0 +1,34 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.content-text-block { + width: 100%; + gap: $gap-smallest; + @include flexColumn(); +} + +.content-image-block { + gap: 8px; + width: 100%; + @include flexColumn(); + + h5 { + color: $color-grey; + text-align: center; + } + + .image-container { + width: 100%; + max-height: 368px; + display: flex; + overflow: hidden; + border-radius: 4px; + background-color: $color-grey; + + img { + width: 100%; + object-fit: cover; + } + } +} diff --git a/src/common/content/index.jsx b/src/common/content/index.jsx new file mode 100644 index 000000000..5d45e8d81 --- /dev/null +++ b/src/common/content/index.jsx @@ -0,0 +1,36 @@ +import { ContentBuilderAction } from "constants/enums"; + +import { TextBlock, ImageBlock } from "./blocks"; + +import "./styles.scss"; + +export const ContentBlocks = ({ contentList = [] }) => { + return ( +
+ {contentList.map((content, index) => { + switch (content.type) { + case ContentBuilderAction.Text: + return ( + + ); + + case ContentBuilderAction.Image: + return ( + + ); + + default: + return null; + } + })} +
+ ); +}; diff --git a/src/common/content/styles.scss b/src/common/content/styles.scss new file mode 100644 index 000000000..5c97d8c67 --- /dev/null +++ b/src/common/content/styles.scss @@ -0,0 +1,8 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.content-blocks-container { + width: 100%; + gap: $gap-small; + @include flexColumn(flex-start, flex-start); +} diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 6aa7e9ee0..7c90c933d 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -1,6 +1,7 @@ import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; +import { ContentBlocks } from "common/content"; import { ActionButton } from "common/buttons/action-button"; import { DashboardLayout } from "layout/dashboard"; @@ -37,7 +38,9 @@ export const CoursePage = () => { />
-
+
+ +
); diff --git a/src/screens/courses/course/styles.scss b/src/screens/courses/course/styles.scss index dd1c7e16c..5aae7d38b 100644 --- a/src/screens/courses/course/styles.scss +++ b/src/screens/courses/course/styles.scss @@ -1,54 +1,38 @@ @import "src/scss/mixins"; +@import "src/scss/variables"; .current-course-page-container { width: 100%; + gap: $gap-small; background-color: transparent; @include flexColumn(flex-start, flex-start); .header-container { width: 100%; - gap: 40px; + gap: $gap-big; @include flexRow(center, space-between); - .info-container { - flex-grow: 1; - gap: 24px; - @include flexRow(flex-start, flex-start); - - .avatar-container { - width: 152px; - height: 152px; - border-radius: 152px; - } - - .info-column-container { - height: 152px; - @include flexColumn(flex-start, space-between); - } - } - button { width: 248px; } } + .course-content-container { + width: 100%; + padding-right: calc(248px + #{$gap-big}); + } + @include breakpoint("tablet", "max") { .header-container { @include flexColumn(flex-start, center); - .info-container { - @include flexRow(center, flex-start); - - .avatar-container { - width: 64px; - height: 64px; - border-radius: 64px; - } - } - button { width: 100%; } } + + .course-content-container { + padding-right: 0px; + } } } diff --git a/src/scss/_colors.scss b/src/scss/_colors.scss index c0bdd6c87..fcbe6220b 100644 --- a/src/scss/_colors.scss +++ b/src/scss/_colors.scss @@ -1,3 +1,43 @@ $color-green: #58bd88; $color-green-hover: #009466; $color-green-active: #007a54; + +$color-black: #141414; +$color-black-hover: #1d2120; +$color-black-active: #27332f; + +$color-white: #eeefef; +$color-white-hover: #cadcd6; +$color-white-active: #9bb0a9; + +$color-grey: #676969; +$color-grey-hover: #4b4d4d; +$color-grey-active: #323333; + +$color-red: #da3443; +$color-red-hover: #a62833; +$color-red-active: #8c212b; + +$color-blue: #3a84d9; +$color-blue-hover: #2862a6; +$color-blue-active: #22538c; + +$color-yellow-light: #dbb471; +$color-yellow-light-hover: #a88a57; +$color-yellow-light-active: #635233; + +$color-yellow: #eaa533; +$color-yellow-hover: #b88128; +$color-yellow-active: #9e6f22; + +$color-dark-green: rgba(88, 189, 136, 0.1); +$color-dark-green-hover: rgba(0, 148, 102, 0.15); +$color-dark-green-active: rgba(0, 122, 84, 0.3); + +$color-dark-white: rgba(238, 239, 239, 0.1); +$color-dark-white-hover: rgba(202, 220, 214, 0.2); +$color-dark-white-active: rgba(186, 207, 200, 0.3); + +$color-dark-red: rgba(218, 52, 67, 0.1); +$color-dark-red-hover: rgba(166, 40, 51, 0.25); +$color-dark-red-active: rgba(140, 33, 43, 0.4); diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index a1a514149..2cedaf005 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -89,14 +89,15 @@ -webkit-box-orient: vertical; } -@mixin flexColumn($alignItems, $justifyContent) { +@mixin flexColumn($alignItems: normal, $justifyContent: normal) { display: flex; flex-direction: column; + align-items: $alignItems; justify-content: $justifyContent; } -@mixin flexRow($alignItems, $justifyContent) { +@mixin flexRow($alignItems: normal, $justifyContent: normal) { display: flex; flex-direction: row; align-items: $alignItems; diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index c39279f02..09bf666b6 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -21,3 +21,7 @@ $padding-mobile: 16px; $margin-desktop: 40px; $margin-mobile: 24px; + +$gap-big: 40px; +$gap-small: 24px; +$gap-smallest: 16px; diff --git a/src/scss/styles.scss b/src/scss/styles.scss index a2ac959ed..baa39a9aa 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -1,6 +1,7 @@ @import "./fonts"; @import "./mixins"; @import "./variables"; +@import "./colors"; body { width: 100%; diff --git a/src/utils/mocked.js b/src/utils/mocked.js index f1d080629..d8fab3c75 100644 --- a/src/utils/mocked.js +++ b/src/utils/mocked.js @@ -1,7 +1,9 @@ +import { ContentBuilderAction } from "constants/enums"; + export const mockedCourses = [ { id: "0", - title: "A Fueling the ethanol industry", + title: "A Fueling the Content", price: 2333, description: "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", @@ -12,6 +14,44 @@ export const mockedCourses = [ progress: 12, members: 20, createdAt: new Date(2021, 11, 17), + + content: [ + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Image, + url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + description: "Combine Harvester swather", + }, + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Text, + // title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Text, + // title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Image, + url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + description: "Combine Harvester swather", + }, + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + ], }, { id: "1", From b997fb05b3dafdfeaaab2e18daa0a90492628fa9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 20 Apr 2022 05:28:24 +0300 Subject: [PATCH 11/34] implement members component --- src/common/content/blocks/styles.scss | 2 +- src/common/content/styles.scss | 2 +- src/common/page-header/styles.scss | 9 ++- src/components/courses/index.js | 1 + src/components/courses/members/index.jsx | 52 +++++++++++++++ src/components/courses/members/styles.scss | 73 ++++++++++++++++++++++ src/layout/dashboard/styles.scss | 13 +++- src/screens/courses/course/index.jsx | 7 +++ src/screens/courses/course/styles.scss | 3 +- src/scss/_mixins.scss | 27 ++++++++ src/scss/_variables.scss | 7 +++ src/utils/mocked.js | 57 +++++++++++++++++ 12 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 src/components/courses/members/index.jsx create mode 100644 src/components/courses/members/styles.scss diff --git a/src/common/content/blocks/styles.scss b/src/common/content/blocks/styles.scss index 6cb1b5f24..f587cd294 100644 --- a/src/common/content/blocks/styles.scss +++ b/src/common/content/blocks/styles.scss @@ -4,7 +4,7 @@ .content-text-block { width: 100%; - gap: $gap-smallest; + gap: $gap-small; @include flexColumn(); } diff --git a/src/common/content/styles.scss b/src/common/content/styles.scss index 5c97d8c67..a3f8a2de0 100644 --- a/src/common/content/styles.scss +++ b/src/common/content/styles.scss @@ -3,6 +3,6 @@ .content-blocks-container { width: 100%; - gap: $gap-small; + gap: $gap-medium; @include flexColumn(flex-start, flex-start); } diff --git a/src/common/page-header/styles.scss b/src/common/page-header/styles.scss index 6b0f7628c..2c4bda352 100644 --- a/src/common/page-header/styles.scss +++ b/src/common/page-header/styles.scss @@ -1,13 +1,20 @@ @import "src/scss/mixins"; +@import "src/scss/variables"; .main-page-header { width: 100%; - padding: 24px 40px; + padding: 24px 0; + // background-color: red; display: flex; flex-direction: column; + align-items: center; .top-header-container { + padding: 0 40px; + width: 100%; + max-width: $max-content-width; + display: flex; flex-shrink: 0; flex-direction: row; diff --git a/src/components/courses/index.js b/src/components/courses/index.js index 6b1b95a49..c5895a106 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -1 +1,2 @@ export * from "./course-list-item"; +export { Members } from "./members"; diff --git a/src/components/courses/members/index.jsx b/src/components/courses/members/index.jsx new file mode 100644 index 000000000..1d38e6147 --- /dev/null +++ b/src/components/courses/members/index.jsx @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import cx from "classnames"; + +import { Icon } from "common/icon"; +import { Avatar } from "common/avatar"; + +import "./styles.scss"; + +const ViewAllComponent = () => ( +
+

View All

+ +
+); + +export const Members = ({ list = [], onSelectMember, onViewAll }) => { + if (!list || list.length === 0) return null; + + const members = list.slice(0, 6); + + const isLastItem = useCallback( + (index) => index === members.length - 1, + [members] + ); + + const getClassName = useCallback( + (index) => { + const classname = "member-container"; + return cx(classname, { [`${classname}-last-member`]: isLastItem(index) }); + }, + [members] + ); + + return ( +
+
+ {members.map((item, index) => ( +
(isLastItem ? onViewAll() : onSelectMember(item.id))} + > + +

{item.name}

+ + {isLastItem(index) && } +
+ ))} +
+
+ ); +}; diff --git a/src/components/courses/members/styles.scss b/src/components/courses/members/styles.scss new file mode 100644 index 000000000..9a33e2a0f --- /dev/null +++ b/src/components/courses/members/styles.scss @@ -0,0 +1,73 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.members-container { + width: 100%; + overflow-y: scroll; + + .scroll-container { + width: fit-content; + height: fit-content; + gap: $gap-big; + @include flexRow(flex-start, flex-start); + + .member-container { + cursor: pointer; + position: relative; + gap: $gap-smallest; + @include flexColumn(center, flex-start); + + .avatar-container { + @include circle(152px); + } + + h4 { + text-align: center; + @include breakEachWord(); + } + + &-last-member { + .avatar-container { + opacity: 0.4; + } + + h4 { + opacity: 0.4; + } + + .view-all-container { + width: 100%; + @include flexRow(center, center); + @include position(absolute, 75px); + + h4 { + width: auto; + opacity: 1; + } + } + } + + &:hover { + opacity: 0.8; + } + } + } + + @include breakpoint("tablet", "max") { + .scroll-container { + gap: $gap-medium; + + .member-container { + .avatar-container { + @include circle(120px); + } + + &-last-member { + .view-all-container { + @include position(absolute, 55px); + } + } + } + } + } +} diff --git a/src/layout/dashboard/styles.scss b/src/layout/dashboard/styles.scss index 2d1981aea..4f29bcbde 100644 --- a/src/layout/dashboard/styles.scss +++ b/src/layout/dashboard/styles.scss @@ -1,10 +1,13 @@ @import "src/scss/mixins"; +@import "src/scss/variables"; .dashboard-container { flex: 1; - display: flex; + overflow: hidden; + display: flex; flex-direction: column; + background-color: transparent; @include supports-safe-area-left { @@ -20,8 +23,14 @@ width: 100%; overflow-y: scroll; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + .page-title-header { width: 100%; + max-width: $max-content-width; margin-top: 16px; margin-bottom: 8px; padding: 0 40px; @@ -29,6 +38,8 @@ } .dashboard-page { + width: 100%; + max-width: $max-content-width; padding: 24px 40px 40px 40px; } diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 7c90c933d..4b6097f9e 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -1,6 +1,7 @@ import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; +import { Members } from "components/courses"; import { ContentBlocks } from "common/content"; import { ActionButton } from "common/buttons/action-button"; @@ -41,6 +42,12 @@ export const CoursePage = () => {
+ + {}} + onViewAll={() => {}} + />
); diff --git a/src/screens/courses/course/styles.scss b/src/screens/courses/course/styles.scss index 5aae7d38b..500614e48 100644 --- a/src/screens/courses/course/styles.scss +++ b/src/screens/courses/course/styles.scss @@ -3,7 +3,8 @@ .current-course-page-container { width: 100%; - gap: $gap-small; + // max-width: 1112px; + gap: $gap-medium; background-color: transparent; @include flexColumn(flex-start, flex-start); diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 2cedaf005..381d06ecf 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -89,6 +89,13 @@ -webkit-box-orient: vertical; } +@mixin breakEachWord() { + width: min-intrinsic; /* old Chrome, Safari */ + width: -webkit-min-content; /* less old Chrome, Safari */ + width: -moz-min-content; /* current Firefox */ + width: min-content; /* current Chrome, Safari; not IE or Edge */ +} + @mixin flexColumn($alignItems: normal, $justifyContent: normal) { display: flex; flex-direction: column; @@ -103,3 +110,23 @@ align-items: $alignItems; justify-content: $justifyContent; } + +@mixin circle($size) { + width: $size; + height: $size; + border-radius: $size; +} + +@mixin position( + $position, + $top: auto, + $right: auto, + $bottom: auto, + $left: auto +) { + top: $top; + left: $left; + right: $right; + bottom: $bottom; + position: $position; +} diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 09bf666b6..293344a4c 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -15,6 +15,8 @@ $media-mobile-max-width: 767px; $media-tablet-max-width: 1023px; $media-laptop-max-width: 1440px; +$max-content-width: 1200px; + $padding-desktop: 80px; $padding-tablet: 40px; $padding-mobile: 16px; @@ -25,3 +27,8 @@ $margin-mobile: 24px; $gap-big: 40px; $gap-small: 24px; $gap-smallest: 16px; + +$gap-big: 40px; +$gap-medium: 24px; +$gap-small: 16px; +$gap-smallest: 8px; diff --git a/src/utils/mocked.js b/src/utils/mocked.js index d8fab3c75..a6a9306bf 100644 --- a/src/utils/mocked.js +++ b/src/utils/mocked.js @@ -52,6 +52,63 @@ export const mockedCourses = [ text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", }, ], + + membersList: [ + { + id: "0", + name: "Jenny Wilson", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "1", + name: "Russel Diane", + }, + { + id: "2", + name: "Alambet Brojik", + avatar: + "https://image.shutterstock.com/image-photo/young-girl-makes-favor-condescendingly-260nw-1287061849.jpg", + }, + { + id: "3", + name: "Resie Cooper", + avatar: + "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", + }, + { + id: "4", + name: "Justin Biber", + }, + { + id: "5", + name: "Amal Kekov", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "6", + name: "Elizhabet Perkins", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "7", + name: "Ronald Richardson", + avatar: + "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", + }, + { + id: "8", + name: "Abram Ibragimov", + }, + { + id: "9", + name: "Sheldon Cooper", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + ], }, { id: "1", From cc8f4abc6f56c1809f644bd334dbb2363e47f953 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 20 Apr 2022 17:21:45 +0300 Subject: [PATCH 12/34] implement two columns grid component --- src/common/grids/index.js | 1 + src/common/grids/two-columns-grid/index.jsx | 44 +++++++++++++++++++ src/common/grids/two-columns-grid/styles.scss | 18 ++++++++ src/screens/courses/course/index.jsx | 14 ++++-- src/screens/courses/course/styles.scss | 27 ------------ src/scss/_variables.scss | 2 +- 6 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 src/common/grids/index.js create mode 100644 src/common/grids/two-columns-grid/index.jsx create mode 100644 src/common/grids/two-columns-grid/styles.scss diff --git a/src/common/grids/index.js b/src/common/grids/index.js new file mode 100644 index 000000000..b2e1ea27e --- /dev/null +++ b/src/common/grids/index.js @@ -0,0 +1 @@ +export { TwoColumnsGrid } from "./two-columns-grid"; diff --git a/src/common/grids/two-columns-grid/index.jsx b/src/common/grids/two-columns-grid/index.jsx new file mode 100644 index 000000000..ce81b8f6b --- /dev/null +++ b/src/common/grids/two-columns-grid/index.jsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import cx from "classnames"; + +import { isMobileUp } from "hooks/useResponsive"; + +import "./styles.scss"; + +export const TwoColumnsGrid = ({ + children, + templateColumns, + reverseMobile = false, +}) => { + const isTablet = isMobileUp(); + + const grid = useMemo( + () => (isTablet ? "1fr" : templateColumns), + [isTablet, templateColumns] + ); + + const className = useMemo(() => { + const classname = "two-columns-grid-container"; + return cx(classname, { + [`${classname}-reversed`]: isTablet && reverseMobile, + }); + }, [isTablet, reverseMobile]); + + return ( +
+ {children.length && ( + <> +
{children[0] && children[0]}
+
{children[1] && children[1]}
+ + )} + + {!children?.length && ( + <> +
{children}
+
+ + )} +
+ ); +}; diff --git a/src/common/grids/two-columns-grid/styles.scss b/src/common/grids/two-columns-grid/styles.scss new file mode 100644 index 000000000..8c674cdc5 --- /dev/null +++ b/src/common/grids/two-columns-grid/styles.scss @@ -0,0 +1,18 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.two-columns-grid-container { + width: 100%; + display: grid; + gap: $gap-big $gap-big; + + &-reversed { + .second-column { + order: -1; + } + } + + @include breakpoint("tablet", "max") { + gap: $gap-medium $gap-medium; + } +} diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 4b6097f9e..3ca9e0afc 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -4,6 +4,7 @@ import { useParams } from "react-router-dom"; import { Members } from "components/courses"; import { ContentBlocks } from "common/content"; import { ActionButton } from "common/buttons/action-button"; +import { TwoColumnsGrid } from "common/grids"; import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; @@ -12,6 +13,8 @@ import { CourseMainInfo } from "./main-info"; import "./styles.scss"; +const gridTemplateColumns = "1fr 248px"; + export const CoursePage = () => { const { id } = useParams(); @@ -22,7 +25,10 @@ export const CoursePage = () => { return (
-
+ { title="Buy course" onClick={handleBuyCourse} /> -
+ -
+ -
+ Date: Wed, 20 Apr 2022 22:56:25 +0300 Subject: [PATCH 13/34] lessons, reviews, members, materials setup and empty states --- src/assets/images/placeholders/file.png | Bin 0 -> 7297 bytes src/assets/images/placeholders/lesson-big.png | Bin 0 -> 31102 bytes .../images/placeholders/lesson-small.png | Bin 0 -> 17736 bytes src/assets/images/placeholders/member.png | Bin 0 -> 6998 bytes src/assets/images/placeholders/review.png | Bin 0 -> 7587 bytes src/common/grids/two-columns-grid/index.jsx | 2 +- .../courses/blocks/empty-block/config.js | 50 ++++++++++++++++++ .../courses/blocks/empty-block/index.jsx | 27 ++++++++++ .../courses/blocks/empty-block/styles.scss | 17 ++++++ .../courses/blocks/header/index.jsx | 19 +++++++ .../courses/blocks/header/styles.scss | 11 ++++ src/components/courses/blocks/index.js | 4 ++ .../courses/blocks/lessons/index.jsx | 13 +++++ .../courses/blocks/lessons/styles.scss | 13 +++++ .../courses/{ => blocks}/members/index.jsx | 2 +- .../courses/{ => blocks}/members/styles.scss | 0 .../courses/blocks/meterials/index.jsx | 36 +++++++++++++ .../courses/blocks/meterials/styles.scss | 9 ++++ .../courses/blocks/reviews/index.jsx | 19 +++++++ .../courses/blocks/reviews/styles.scss | 9 ++++ src/components/courses/index.js | 1 + src/screens/courses/course/index.jsx | 25 +++++++-- src/screens/courses/course/styles.scss | 5 ++ 23 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 src/assets/images/placeholders/file.png create mode 100644 src/assets/images/placeholders/lesson-big.png create mode 100644 src/assets/images/placeholders/lesson-small.png create mode 100644 src/assets/images/placeholders/member.png create mode 100644 src/assets/images/placeholders/review.png create mode 100644 src/components/courses/blocks/empty-block/config.js create mode 100644 src/components/courses/blocks/empty-block/index.jsx create mode 100644 src/components/courses/blocks/empty-block/styles.scss create mode 100644 src/components/courses/blocks/header/index.jsx create mode 100644 src/components/courses/blocks/header/styles.scss create mode 100644 src/components/courses/blocks/index.js create mode 100644 src/components/courses/blocks/lessons/index.jsx create mode 100644 src/components/courses/blocks/lessons/styles.scss rename src/components/courses/{ => blocks}/members/index.jsx (94%) rename src/components/courses/{ => blocks}/members/styles.scss (100%) create mode 100644 src/components/courses/blocks/meterials/index.jsx create mode 100644 src/components/courses/blocks/meterials/styles.scss create mode 100644 src/components/courses/blocks/reviews/index.jsx create mode 100644 src/components/courses/blocks/reviews/styles.scss diff --git a/src/assets/images/placeholders/file.png b/src/assets/images/placeholders/file.png new file mode 100644 index 0000000000000000000000000000000000000000..5126009a6be0a4960421eb85d928b209da6adbae GIT binary patch literal 7297 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 bc7N<|h?lpjUk}0900000NkvXXu0mjfooGAj literal 0 HcmV?d00001 diff --git a/src/assets/images/placeholders/lesson-big.png b/src/assets/images/placeholders/lesson-big.png new file mode 100644 index 0000000000000000000000000000000000000000..6c729a8b6edc4326bd69c104e36eac17a24154be GIT binary patch literal 31102 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/placeholders/lesson-small.png b/src/assets/images/placeholders/lesson-small.png new file mode 100644 index 0000000000000000000000000000000000000000..28efe0fe9b82cc98bf8c00551a08831a0d7aa0b2 GIT binary patch literal 17736 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 O0000!@pP)B@JUwq+()TK+8 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 - {children.length && ( + {children?.length && ( <>
{children[0] && children[0]}
{children[1] && children[1]}
diff --git a/src/components/courses/blocks/empty-block/config.js b/src/components/courses/blocks/empty-block/config.js new file mode 100644 index 000000000..9b03a32ce --- /dev/null +++ b/src/components/courses/blocks/empty-block/config.js @@ -0,0 +1,50 @@ +import materialImage from "assets/images/placeholders/file.png"; +import lessonSmallImage from "assets/images/placeholders/lesson-small.png"; +import lessonBigImage from "assets/images/placeholders/lesson-big.png"; +import memberImage from "assets/images/placeholders/member.png"; +import reviewImage from "assets/images/placeholders/review.png"; + +export const Variant = { + Material: "Material", + Member: "Member", + Review: "Review", + Lesson: "Lesson", +}; + +export const ImageSize = { + small: "small", + big: "big", +}; + +export const Image = { + [Variant.Material]: { + [ImageSize.big]: materialImage, + [ImageSize.small]: materialImage, + }, + [Variant.Lesson]: { + [ImageSize.big]: lessonBigImage, + [ImageSize.small]: lessonSmallImage, + }, + [Variant.Member]: { + [ImageSize.big]: memberImage, + [ImageSize.small]: memberImage, + }, + [Variant.Review]: { + [ImageSize.big]: reviewImage, + [ImageSize.small]: reviewImage, + }, +}; + +export const ButtonTitle = { + [Variant.Material]: "Add Materials", + [Variant.Lesson]: "Add Lesson", + [Variant.Member]: "Invite", + [Variant.Review]: "Add Review", +}; + +export const ButtonVariant = { + [Variant.Material]: "secondary", + [Variant.Lesson]: "secondary", + [Variant.Member]: "secondary", + [Variant.Review]: "secondary", +}; diff --git a/src/components/courses/blocks/empty-block/index.jsx b/src/components/courses/blocks/empty-block/index.jsx new file mode 100644 index 000000000..310d88899 --- /dev/null +++ b/src/components/courses/blocks/empty-block/index.jsx @@ -0,0 +1,27 @@ +import { ActionButton } from "common/buttons/action-button"; + +import * as Config from "./config"; + +import "./styles.scss"; + +export const EmptyBllock = ({ + onAdd, + variant = Config.Variant.Review, + imageSize = Config.ImageSize.small, +}) => { + const buttonTitle = Config.ButtonTitle[variant]; + const image = Config.Image[variant][imageSize]; + const buttonVariant = Config.ButtonVariant[variant]; + + return ( +
+ + +
+ ); +}; diff --git a/src/components/courses/blocks/empty-block/styles.scss b/src/components/courses/blocks/empty-block/styles.scss new file mode 100644 index 000000000..5edb1fbfb --- /dev/null +++ b/src/components/courses/blocks/empty-block/styles.scss @@ -0,0 +1,17 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.courses-empty-block-container { + width: 100%; + gap: $gap-medium; + @include flexColumn(center, center); + + img { + height: 100%; + width: fit-content; + } + + button { + width: 100%; + } +} diff --git a/src/components/courses/blocks/header/index.jsx b/src/components/courses/blocks/header/index.jsx new file mode 100644 index 000000000..a05990742 --- /dev/null +++ b/src/components/courses/blocks/header/index.jsx @@ -0,0 +1,19 @@ +import { ActionButton } from "common/buttons/action-button"; + +import "./styles.scss"; + +export const BlockHeader = ({ title, onViewAll }) => { + return ( +
+

{title}

+ + {onViewAll && ( + + )} +
+ ); +}; diff --git a/src/components/courses/blocks/header/styles.scss b/src/components/courses/blocks/header/styles.scss new file mode 100644 index 000000000..7ab6fc74b --- /dev/null +++ b/src/components/courses/blocks/header/styles.scss @@ -0,0 +1,11 @@ +@import "src/scss/mixins"; + +.block-header-container { + width: 100%; + height: 64px; + @include flexRow(center, space-between); + + button { + width: fit-content; + } +} diff --git a/src/components/courses/blocks/index.js b/src/components/courses/blocks/index.js new file mode 100644 index 000000000..245fe5bb3 --- /dev/null +++ b/src/components/courses/blocks/index.js @@ -0,0 +1,4 @@ +export * from "./meterials"; +export * from "./members"; +export * from "./lessons"; +export * from "./reviews"; diff --git a/src/components/courses/blocks/lessons/index.jsx b/src/components/courses/blocks/lessons/index.jsx new file mode 100644 index 000000000..2468f9699 --- /dev/null +++ b/src/components/courses/blocks/lessons/index.jsx @@ -0,0 +1,13 @@ +import { EmptyBllock } from "../empty-block"; +import { BlockHeader } from "../header"; + +import "./styles.scss"; + +export const LessonsBlock = ({ lessons = [] }) => { + return ( +
+ + {lessons.length === 0 && } +
+ ); +}; diff --git a/src/components/courses/blocks/lessons/styles.scss b/src/components/courses/blocks/lessons/styles.scss new file mode 100644 index 000000000..9aeda923b --- /dev/null +++ b/src/components/courses/blocks/lessons/styles.scss @@ -0,0 +1,13 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.lessons-block-container { + width: 100%; + gap: $gap-big; + @include flexColumn(flex-start, flex-start); + + .courses-empty-block-container { + gap: $gap-big; + } +} diff --git a/src/components/courses/members/index.jsx b/src/components/courses/blocks/members/index.jsx similarity index 94% rename from src/components/courses/members/index.jsx rename to src/components/courses/blocks/members/index.jsx index 1d38e6147..77ce20025 100644 --- a/src/components/courses/members/index.jsx +++ b/src/components/courses/blocks/members/index.jsx @@ -13,7 +13,7 @@ const ViewAllComponent = () => (
); -export const Members = ({ list = [], onSelectMember, onViewAll }) => { +export const MembersBlock = ({ list = [], onSelectMember, onViewAll }) => { if (!list || list.length === 0) return null; const members = list.slice(0, 6); diff --git a/src/components/courses/members/styles.scss b/src/components/courses/blocks/members/styles.scss similarity index 100% rename from src/components/courses/members/styles.scss rename to src/components/courses/blocks/members/styles.scss diff --git a/src/components/courses/blocks/meterials/index.jsx b/src/components/courses/blocks/meterials/index.jsx new file mode 100644 index 000000000..020a72c8d --- /dev/null +++ b/src/components/courses/blocks/meterials/index.jsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +import { BlockHeader } from "../header"; +import { EmptyBllock } from "../empty-block"; + +import "./styles.scss"; + +export const MeterialsList = ({ materials = [] }) => { + if (materials.length === 0) { + return null; + } + + return
; +}; + +export const MeterialsBlock = ({ materials = [] }) => { + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + + return ( +
+ {}} /> + + + + {materials.length === 0 && ( + setIsAddModalVisible(!isAddModalVisible)} + /> + )} +
+ ); +}; + +export const MaterialsModal = () => {}; diff --git a/src/components/courses/blocks/meterials/styles.scss b/src/components/courses/blocks/meterials/styles.scss new file mode 100644 index 000000000..416fdc4b0 --- /dev/null +++ b/src/components/courses/blocks/meterials/styles.scss @@ -0,0 +1,9 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.materials-block-container { + width: 100%; + gap: $gap-small; + @include flexColumn(flex-start, flex-start); +} diff --git a/src/components/courses/blocks/reviews/index.jsx b/src/components/courses/blocks/reviews/index.jsx new file mode 100644 index 000000000..86cdebeec --- /dev/null +++ b/src/components/courses/blocks/reviews/index.jsx @@ -0,0 +1,19 @@ +import { BlockHeader } from "../header"; +import { EmptyBllock } from "../empty-block"; + +import "./styles.scss"; + +export const ReviewsBlock = ({ reviews = [] }) => { + return ( +
+ 0 ? () => {} : null} + /> + + {reviews.length === 0 && ( + {}} /> + )} +
+ ); +}; diff --git a/src/components/courses/blocks/reviews/styles.scss b/src/components/courses/blocks/reviews/styles.scss new file mode 100644 index 000000000..2bf29eb4e --- /dev/null +++ b/src/components/courses/blocks/reviews/styles.scss @@ -0,0 +1,9 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.reviews-block-container { + width: 100%; + gap: $gap-small; + @include flexColumn(flex-start, flex-start); +} diff --git a/src/components/courses/index.js b/src/components/courses/index.js index c5895a106..d73e71964 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -1,2 +1,3 @@ export * from "./course-list-item"; export { Members } from "./members"; +export * from "./meterials"; diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 3ca9e0afc..710b9daa4 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -1,10 +1,16 @@ +import { useState } from "react"; import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; -import { Members } from "components/courses"; +import { TwoColumnsGrid } from "common/grids"; import { ContentBlocks } from "common/content"; import { ActionButton } from "common/buttons/action-button"; -import { TwoColumnsGrid } from "common/grids"; +import { + MembersBlock, + ReviewsBlock, + LessonsBlock, + MeterialsBlock, +} from "components/courses/blocks"; import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; @@ -17,9 +23,11 @@ const gridTemplateColumns = "1fr 248px"; export const CoursePage = () => { const { id } = useParams(); - const course = useSelector((state) => selectCurrentCourse(state, id)); + const [isReviewsVisible, setIsReviewsVisible] = useState(false); + const [isMaterialsVisible, setMaterialsVisible] = useState(false); + const handleBuyCourse = () => {}; return ( @@ -49,11 +57,20 @@ export const CoursePage = () => { - {}} onViewAll={() => {}} /> + + + + +
+ + +
+
); diff --git a/src/screens/courses/course/styles.scss b/src/screens/courses/course/styles.scss index 2d82c8cfb..7aae2c6ac 100644 --- a/src/screens/courses/course/styles.scss +++ b/src/screens/courses/course/styles.scss @@ -7,6 +7,11 @@ background-color: transparent; @include flexColumn(flex-start, flex-start); + .column-container { + gap: $gap-big; + @include flexColumn(); + } + @include breakpoint("tablet", "max") { } } From 0fda3651b07bcf63134c8567b1bbf5021d716539 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 22 Apr 2022 04:07:25 +0300 Subject: [PATCH 14/34] implement materials block --- src/assets/icons/checkmark.svg | 4 +- src/assets/icons/download.svg | 4 + src/assets/icons/files/doc.svg | 7 + src/assets/icons/files/jpg.svg | 7 + src/assets/icons/files/pdf.svg | 7 + src/assets/icons/files/png.svg | 7 + src/assets/icons/files/txt.svg | 7 + src/assets/icons/files/xls.svg | 7 + src/assets/icons/files/zip.svg | 7 + src/common/buttons/download-button/index.jsx | 25 +++ .../buttons/download-button/styles.scss | 20 ++ src/common/icon/index.jsx | 36 ++++ src/common/progress/circular/index.jsx | 62 ++++++ src/common/progress/circular/styles.scss | 29 +++ src/common/progress/index.js | 0 .../courses/blocks/empty-block/index.jsx | 33 +++- .../courses/blocks/empty-block/styles.scss | 1 - .../courses/blocks/meterials/config.js | 11 ++ .../courses/blocks/meterials/index.jsx | 67 +++++-- .../courses/blocks/meterials/styles.scss | 15 ++ src/components/courses/index.js | 2 - src/constants/enums.js | 10 + src/screens/courses/course/helpers.js | 6 + src/screens/courses/course/index.jsx | 3 +- src/scss/_variables.scss | 4 - src/utils/mocked/courses.js | 180 ++++++++++++++++++ src/utils/mocked/materials.js | 44 +++++ 27 files changed, 576 insertions(+), 29 deletions(-) create mode 100644 src/assets/icons/download.svg create mode 100644 src/assets/icons/files/doc.svg create mode 100644 src/assets/icons/files/jpg.svg create mode 100644 src/assets/icons/files/pdf.svg create mode 100644 src/assets/icons/files/png.svg create mode 100644 src/assets/icons/files/txt.svg create mode 100644 src/assets/icons/files/xls.svg create mode 100644 src/assets/icons/files/zip.svg create mode 100644 src/common/buttons/download-button/index.jsx create mode 100644 src/common/buttons/download-button/styles.scss create mode 100644 src/common/progress/circular/index.jsx create mode 100644 src/common/progress/circular/styles.scss create mode 100644 src/common/progress/index.js create mode 100644 src/components/courses/blocks/meterials/config.js create mode 100644 src/screens/courses/course/helpers.js create mode 100644 src/utils/mocked/courses.js create mode 100644 src/utils/mocked/materials.js diff --git a/src/assets/icons/checkmark.svg b/src/assets/icons/checkmark.svg index bd0cab761..0a73635a6 100644 --- a/src/assets/icons/checkmark.svg +++ b/src/assets/icons/checkmark.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/download.svg b/src/assets/icons/download.svg new file mode 100644 index 000000000..431649916 --- /dev/null +++ b/src/assets/icons/download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/files/doc.svg b/src/assets/icons/files/doc.svg new file mode 100644 index 000000000..db4d7b7fe --- /dev/null +++ b/src/assets/icons/files/doc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/jpg.svg b/src/assets/icons/files/jpg.svg new file mode 100644 index 000000000..ba2655fb1 --- /dev/null +++ b/src/assets/icons/files/jpg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/pdf.svg b/src/assets/icons/files/pdf.svg new file mode 100644 index 000000000..7d36897e6 --- /dev/null +++ b/src/assets/icons/files/pdf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/png.svg b/src/assets/icons/files/png.svg new file mode 100644 index 000000000..23de6e354 --- /dev/null +++ b/src/assets/icons/files/png.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/txt.svg b/src/assets/icons/files/txt.svg new file mode 100644 index 000000000..8fc25df22 --- /dev/null +++ b/src/assets/icons/files/txt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/xls.svg b/src/assets/icons/files/xls.svg new file mode 100644 index 000000000..d398dd13d --- /dev/null +++ b/src/assets/icons/files/xls.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/files/zip.svg b/src/assets/icons/files/zip.svg new file mode 100644 index 000000000..21f629900 --- /dev/null +++ b/src/assets/icons/files/zip.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/common/buttons/download-button/index.jsx b/src/common/buttons/download-button/index.jsx new file mode 100644 index 000000000..298f28f0d --- /dev/null +++ b/src/common/buttons/download-button/index.jsx @@ -0,0 +1,25 @@ +import { useMemo } from "react"; + +import { Icon } from "common/icon"; +import { CircularProgress } from "common/progress/circular"; + +import "./styles.scss"; + +export const DownloadButton = ({ + onClick, + isCompleted = false, + isDownloading = false, +}) => { + const icon = useMemo(() => { + return isCompleted ? "checkmark" : "download"; + }, [isCompleted]); + + return ( + + ); +}; diff --git a/src/common/buttons/download-button/styles.scss b/src/common/buttons/download-button/styles.scss new file mode 100644 index 000000000..7b705653c --- /dev/null +++ b/src/common/buttons/download-button/styles.scss @@ -0,0 +1,20 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; + +.download-button { + width: 24px; + height: 24px; + border: none; + cursor: pointer; + background-color: transparent; + + @include setSvgColor($color-green); + + &:hover { + @include setSvgColor($color-green-hover); + } + + &:active { + @include setSvgColor($color-green-active); + } +} diff --git a/src/common/icon/index.jsx b/src/common/icon/index.jsx index af852640f..6d1beda46 100644 --- a/src/common/icon/index.jsx +++ b/src/common/icon/index.jsx @@ -15,6 +15,15 @@ import { ReactComponent as ChevronRightIcon } from "assets/icons/chevron/right.s import { ReactComponent as ChevronUpIcon } from "assets/icons/chevron/up.svg"; import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron/down.svg"; +// Files +import { ReactComponent as FileDocIcon } from "assets/icons/files/doc.svg"; +import { ReactComponent as FileJpgIcon } from "assets/icons/files/jpg.svg"; +import { ReactComponent as FilePdfIcon } from "assets/icons/files/pdf.svg"; +import { ReactComponent as FilePngIcon } from "assets/icons/files/png.svg"; +import { ReactComponent as FileTxtIcon } from "assets/icons/files/txt.svg"; +import { ReactComponent as FileXlsIcon } from "assets/icons/files/xls.svg"; +import { ReactComponent as FileZioIcon } from "assets/icons/files/zip.svg"; + import { ReactComponent as PersonIcon } from "assets/icons/person.svg"; import { ReactComponent as EyeOpenIcon } from "assets/icons/eye-on.svg"; import { ReactComponent as EyeCloseIcon } from "assets/icons/eye-off.svg"; @@ -46,6 +55,7 @@ import { ReactComponent as TwitterIcon } from "assets/icons/twitter.svg"; import { ReactComponent as EditIcon } from "assets/icons/edit.svg"; import { ReactComponent as StarOutlineIcon } from "assets/icons/star-outline.svg"; import { ReactComponent as StarIcon } from "assets/icons/star.svg"; +import { ReactComponent as DownloadIcon } from "assets/icons/download.svg"; const getIcon = (iconName) => { switch (iconName) { @@ -88,6 +98,9 @@ const getIcon = (iconName) => { case "chevrons-right": return ; + case "download": + return ; + case "edit": return ; @@ -169,6 +182,29 @@ const getIcon = (iconName) => { case "youtube": return ; + // Files + + case "file-doc": + return ; + + case "file-jpg": + return ; + + case "file-pdf": + return ; + + case "file-png": + return ; + + case "file-txt": + return ; + + case "file-xls": + return ; + + case "file-zip": + return ; + default: return ; } diff --git a/src/common/progress/circular/index.jsx b/src/common/progress/circular/index.jsx new file mode 100644 index 000000000..2a1e9a093 --- /dev/null +++ b/src/common/progress/circular/index.jsx @@ -0,0 +1,62 @@ +import { useEffect, useState, useMemo } from "react"; +import cx from "classnames"; + +import "./styles.scss"; + +const CircularProgress = ({ + size, + variant, + percentage, + strokeWidth = 2, + isInfinite = false, +}) => { + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (isInfinite) setProgress(50); + else setProgress(percentage); + }, [percentage, isInfinite]); + + const className = useMemo(() => { + const classname = "progress-circle"; + return cx(classname, { + [`${classname}-${variant}`]: true, + [`${classname}-infinite`]: isInfinite, + }); + }, [variant, isInfinite]); + + const radius = (size - strokeWidth) / 2; + const circumference = radius * Math.PI * 2; + const dash = (progress * circumference) / 100; + + return ( + + + + + ); +}; + +export { CircularProgress }; diff --git a/src/common/progress/circular/styles.scss b/src/common/progress/circular/styles.scss new file mode 100644 index 000000000..94f06df3d --- /dev/null +++ b/src/common/progress/circular/styles.scss @@ -0,0 +1,29 @@ +@import "src/scss/colors"; + +.progress-circle { + .inner-circle { + fill: none; + } + + .outter-circle { + fill: none; + transition: all 0.5s; + } + + &-green { + .outter-circle { + stroke: $color-green; + } + } + + &-infinite { + animation: spin 2s linear infinite; + } +} + +@keyframes spin { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/common/progress/index.js b/src/common/progress/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/courses/blocks/empty-block/index.jsx b/src/components/courses/blocks/empty-block/index.jsx index 310d88899..f30b0080c 100644 --- a/src/components/courses/blocks/empty-block/index.jsx +++ b/src/components/courses/blocks/empty-block/index.jsx @@ -1,3 +1,6 @@ +import { useMemo } from "react"; + +import { isMobileUp } from "hooks/useResponsive"; import { ActionButton } from "common/buttons/action-button"; import * as Config from "./config"; @@ -6,22 +9,36 @@ import "./styles.scss"; export const EmptyBllock = ({ onAdd, + isImageVisible = true, + isAddButtonVisible = true, variant = Config.Variant.Review, imageSize = Config.ImageSize.small, }) => { + if (!isImageVisible && !isAddButtonVisible) return null; + + const isMobile = isMobileUp(); + const buttonTitle = Config.ButtonTitle[variant]; - const image = Config.Image[variant][imageSize]; const buttonVariant = Config.ButtonVariant[variant]; + const image = useMemo(() => { + return isMobile + ? Config.Image[variant][Config.ImageSize.small] + : Config.Image[variant][imageSize]; + }, [isMobile, variant, imageSize]); + return (
- - + {isImageVisible && } + + {isAddButtonVisible && ( + + )}
); }; diff --git a/src/components/courses/blocks/empty-block/styles.scss b/src/components/courses/blocks/empty-block/styles.scss index 5edb1fbfb..1f7f3c03c 100644 --- a/src/components/courses/blocks/empty-block/styles.scss +++ b/src/components/courses/blocks/empty-block/styles.scss @@ -8,7 +8,6 @@ img { height: 100%; - width: fit-content; } button { diff --git a/src/components/courses/blocks/meterials/config.js b/src/components/courses/blocks/meterials/config.js new file mode 100644 index 000000000..2718da380 --- /dev/null +++ b/src/components/courses/blocks/meterials/config.js @@ -0,0 +1,11 @@ +import { FileExtension } from "constants/enums"; + +export const FileIconName = { + [FileExtension.zip]: "file-zip", + [FileExtension.xls]: "file-xls", + [FileExtension.doc]: "file-doc", + [FileExtension.txt]: "file-txt", + [FileExtension.pdf]: "file-pdf", + [FileExtension.jpg]: "file-jpg", + [FileExtension.png]: "file-png", +}; diff --git a/src/components/courses/blocks/meterials/index.jsx b/src/components/courses/blocks/meterials/index.jsx index 020a72c8d..f5db0314f 100644 --- a/src/components/courses/blocks/meterials/index.jsx +++ b/src/components/courses/blocks/meterials/index.jsx @@ -1,34 +1,79 @@ import { useState } from "react"; +import { Icon } from "common/icon"; +import { DownloadButton } from "common/buttons/download-button"; + import { BlockHeader } from "../header"; import { EmptyBllock } from "../empty-block"; +import { FileIconName } from "./config"; + import "./styles.scss"; -export const MeterialsList = ({ materials = [] }) => { +const Material = ({ material }) => { + const [isCompleted, setIsCompleted] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownloadClick = () => { + setIsDownloading(true); + + setTimeout(() => { + setIsDownloading(false); + setIsCompleted(true); + }, 2000); + }; + + return ( +
+
+ +
{`${material.name}.${material.extension}`}
+
+ + +
+ ); +}; + +export const MeterialsList = ({ materials = [], maxLength }) => { if (materials.length === 0) { return null; } - return
; + const list = maxLength ? materials.slice(0, maxLength) : materials; + + return ( +
+ {list.map((material, index) => ( + + ))} +
+ ); }; -export const MeterialsBlock = ({ materials = [] }) => { +export const MeterialsBlock = ({ materials = [], withAddButton = true }) => { const [isAddModalVisible, setIsAddModalVisible] = useState(false); return (
{}} /> - + - {materials.length === 0 && ( - setIsAddModalVisible(!isAddModalVisible)} - /> - )} + setIsAddModalVisible(!isAddModalVisible)} + />
); }; diff --git a/src/components/courses/blocks/meterials/styles.scss b/src/components/courses/blocks/meterials/styles.scss index 416fdc4b0..6fd30de28 100644 --- a/src/components/courses/blocks/meterials/styles.scss +++ b/src/components/courses/blocks/meterials/styles.scss @@ -7,3 +7,18 @@ gap: $gap-small; @include flexColumn(flex-start, flex-start); } + +.materials-list-container { + width: 100%; + gap: $gap-medium; + @include flexColumn(); + + .material-container { + @include flexRow(center, space-between); + + .info-container { + gap: $gap-smallest; + @include flexRow(); + } + } +} diff --git a/src/components/courses/index.js b/src/components/courses/index.js index d73e71964..6b1b95a49 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -1,3 +1 @@ export * from "./course-list-item"; -export { Members } from "./members"; -export * from "./meterials"; diff --git a/src/constants/enums.js b/src/constants/enums.js index 96cbf9efa..7f2e20c74 100644 --- a/src/constants/enums.js +++ b/src/constants/enums.js @@ -47,3 +47,13 @@ export const CourseListType = { All: "All", Paid: "Paid", }; + +export const FileExtension = { + zip: "zip", + xls: "xls", + doc: "doc", + txt: "txt", + pdf: "pdf", + jpg: "jpg", + png: "png", +}; diff --git a/src/screens/courses/course/helpers.js b/src/screens/courses/course/helpers.js new file mode 100644 index 000000000..7aefbe456 --- /dev/null +++ b/src/screens/courses/course/helpers.js @@ -0,0 +1,6 @@ +import { mockedMaterials } from "utils/mocked/materials"; + +export const getCourseMatarials = (courseId) => { + if (!courseId) return []; + return mockedMaterials[courseId]?.materials || []; +}; diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 710b9daa4..75de95022 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -16,6 +16,7 @@ import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; import { CourseMainInfo } from "./main-info"; +import { getCourseMatarials } from "./helpers"; import "./styles.scss"; @@ -67,7 +68,7 @@ export const CoursePage = () => {
- +
diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index b235a2117..8e5b99ca9 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -24,10 +24,6 @@ $padding-mobile: 16px; $margin-desktop: 40px; $margin-mobile: 24px; -$gap-big: 40px; -$gap-small: 24px; -$gap-smallest: 16px; - $gap-big: 40px; $gap-medium: 24px; $gap-small: 16px; diff --git a/src/utils/mocked/courses.js b/src/utils/mocked/courses.js new file mode 100644 index 000000000..a6a9306bf --- /dev/null +++ b/src/utils/mocked/courses.js @@ -0,0 +1,180 @@ +import { ContentBuilderAction } from "constants/enums"; + +export const mockedCourses = [ + { + id: "0", + title: "A Fueling the Content", + price: 2333, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 4, + progress: 12, + members: 20, + createdAt: new Date(2021, 11, 17), + + content: [ + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Image, + url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + description: "Combine Harvester swather", + }, + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Text, + // title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Text, + // title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + { + type: ContentBuilderAction.Image, + url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + description: "Combine Harvester swather", + }, + { + type: ContentBuilderAction.Text, + title: "Senectus sed facilisis egestas adipiscing mauris.", + text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", + }, + ], + + membersList: [ + { + id: "0", + name: "Jenny Wilson", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "1", + name: "Russel Diane", + }, + { + id: "2", + name: "Alambet Brojik", + avatar: + "https://image.shutterstock.com/image-photo/young-girl-makes-favor-condescendingly-260nw-1287061849.jpg", + }, + { + id: "3", + name: "Resie Cooper", + avatar: + "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", + }, + { + id: "4", + name: "Justin Biber", + }, + { + id: "5", + name: "Amal Kekov", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "6", + name: "Elizhabet Perkins", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + { + id: "7", + name: "Ronald Richardson", + avatar: + "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", + }, + { + id: "8", + name: "Abram Ibragimov", + }, + { + id: "9", + name: "Sheldon Cooper", + avatar: + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", + }, + ], + }, + { + id: "1", + title: "B Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 5, + progress: 70, + members: 10, + createdAt: new Date(2020, 5, 17), + }, + { + id: "2", + title: "C Fueling the ethanol industry", + price: 122, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 5, + progress: 40, + members: 50, + createdAt: new Date(2020, 11, 5), + }, + { + id: "3", + title: "Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 1, + progress: 40, + members: 100, + createdAt: new Date(2022, 4, 17), + }, + { + id: "4", + title: "Fueling the ethanol industry", + price: 2599, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: null, + category: "Farm", + rating: 2, + progress: 45, + members: 0, + createdAt: new Date(2022, 11, 17), + }, + { + id: "5", + title: "Fueling the ethanol industry", + price: 3400, + description: + "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", + avatar: + "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", + category: "Farm", + rating: 3, + progress: 90, + members: 200, + createdAt: new Date(2022, 11, 17), + }, +]; diff --git a/src/utils/mocked/materials.js b/src/utils/mocked/materials.js new file mode 100644 index 000000000..b68466638 --- /dev/null +++ b/src/utils/mocked/materials.js @@ -0,0 +1,44 @@ +import { FileExtension } from "constants/enums"; + +export const mockedMaterials = [ + { + courseId: "0", + materials: [ + { + id: "0", + name: "Course Document", + extension: FileExtension.zip, + }, + { + id: "1", + name: "Course Document", + extension: FileExtension.xls, + }, + { + id: "2", + name: "Course Document", + extension: FileExtension.doc, + }, + { + id: "3", + name: "Course Document", + extension: FileExtension.txt, + }, + { + id: "4", + name: "Course Document", + extension: FileExtension.pdf, + }, + { + id: "5", + name: "Course Document", + extension: FileExtension.jpg, + }, + { + id: "6", + name: "Course Document", + extension: FileExtension.png, + }, + ], + }, +]; From 1c715bc002447f7bc13b452cffafb4ff164cce04 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 22 Apr 2022 05:18:42 +0300 Subject: [PATCH 15/34] materials side modal --- src/assets/icons/trash.svg | 2 +- src/common/buttons/icon-button/styles.scss | 14 ++++ src/common/side-modal/index.jsx | 18 ++--- .../courses/blocks/meterials/index.jsx | 67 +++++++++++++++---- .../courses/blocks/meterials/styles.scss | 14 ++++ src/screens/courses/course/index.jsx | 8 ++- .../news/news-list/filters-modal/index.jsx | 3 +- src/scss/_mixins.scss | 15 +++++ 8 files changed, 115 insertions(+), 26 deletions(-) diff --git a/src/assets/icons/trash.svg b/src/assets/icons/trash.svg index 909ea8495..52073d21f 100644 --- a/src/assets/icons/trash.svg +++ b/src/assets/icons/trash.svg @@ -1,3 +1,3 @@ - + diff --git a/src/common/buttons/icon-button/styles.scss b/src/common/buttons/icon-button/styles.scss index 4fbd76fe6..83d113d01 100644 --- a/src/common/buttons/icon-button/styles.scss +++ b/src/common/buttons/icon-button/styles.scss @@ -1,4 +1,5 @@ @import "src/scss/mixins"; +@import "src/scss/colors"; @mixin flexCenter() { display: flex; @@ -197,4 +198,17 @@ cursor: default; } } + + &-transparent-red { + background-color: transparent; + @include setSvgColor($color-red); + + &:hover:enabled { + @include setSvgColor($color-red-hover); + } + + &:active:enabled { + @include setSvgColor($color-red-active); + } + } } diff --git a/src/common/side-modal/index.jsx b/src/common/side-modal/index.jsx index ed85b1789..a7e52b873 100644 --- a/src/common/side-modal/index.jsx +++ b/src/common/side-modal/index.jsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Portal } from "react-portal"; import { IconButton } from "common/buttons/icon-button"; @@ -5,16 +6,21 @@ import { ActionButton } from "common/buttons/action-button"; import "./styles.scss"; +const actionBtnConfig = { variant: "primary", title: "", onClick: () => {} }; + export const SideModal = ({ title, visible, onClose, children, - actionTitle, - onActionClick, + actionProps = undefined, }) => { if (!visible) return null; + const buttonProps = useMemo(() => { + return { ...actionBtnConfig, ...(actionProps && actionProps) }; + }, [actionProps]); + return (
@@ -28,13 +34,9 @@ export const SideModal = ({
{children}
- {actionTitle && onActionClick && ( + {actionProps && (
- +
)}
diff --git a/src/components/courses/blocks/meterials/index.jsx b/src/components/courses/blocks/meterials/index.jsx index f5db0314f..4045173f7 100644 --- a/src/components/courses/blocks/meterials/index.jsx +++ b/src/components/courses/blocks/meterials/index.jsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { Icon } from "common/icon"; +import { SideModal } from "common/side-modal"; +import { IconButton } from "common/buttons/icon-button"; import { DownloadButton } from "common/buttons/download-button"; import { BlockHeader } from "../header"; @@ -10,10 +12,11 @@ import { FileIconName } from "./config"; import "./styles.scss"; -const Material = ({ material }) => { +const Material = ({ material, isEditMode = true }) => { const [isCompleted, setIsCompleted] = useState(false); const [isDownloading, setIsDownloading] = useState(false); + // TODO: Implement downloading functionality; const handleDownloadClick = () => { setIsDownloading(true); @@ -23,6 +26,9 @@ const Material = ({ material }) => { }, 2000); }; + // TODO: Remove Material from related course; + const handleRemoveClick = () => {}; + return (
@@ -30,16 +36,26 @@ const Material = ({ material }) => {
{`${material.name}.${material.extension}`}
- +
+ + + {isEditMode && ( + + )} +
); }; -export const MeterialsList = ({ materials = [], maxLength }) => { +const MaterialsList = ({ materials = [], maxLength, isEditMode = false }) => { if (materials.length === 0) { return null; } @@ -51,6 +67,7 @@ export const MeterialsList = ({ materials = [], maxLength }) => { {list.map((material, index) => ( ))} @@ -58,24 +75,46 @@ export const MeterialsList = ({ materials = [], maxLength }) => { ); }; -export const MeterialsBlock = ({ materials = [], withAddButton = true }) => { - const [isAddModalVisible, setIsAddModalVisible] = useState(false); +export const MeterialsBlock = ({ + materials = [], + isEditMode = false, + withAddButton = true, +}) => { + const [isAllVisible, setIsAllVisible] = useState(false); + const [isAddVisible, setIsAddVisible] = useState(false); return (
- {}} /> + 0 ? () => setIsAllVisible(true) : null} + /> - + setIsAddVisible(true)} isImageVisible={materials.length === 0} - onAdd={() => setIsAddModalVisible(!isAddModalVisible)} /> + + setIsAllVisible(false)} + actionProps={{ + icon: "plus", + variant: "secondary", + title: "Add Material", + onClick: () => setIsAddVisible(true), + }} + > +
+ +
+
); }; - -export const MaterialsModal = () => {}; diff --git a/src/components/courses/blocks/meterials/styles.scss b/src/components/courses/blocks/meterials/styles.scss index 6fd30de28..5d1692293 100644 --- a/src/components/courses/blocks/meterials/styles.scss +++ b/src/components/courses/blocks/meterials/styles.scss @@ -20,5 +20,19 @@ gap: $gap-smallest; @include flexRow(); } + + .buttons-container { + gap: $gap-small; + @include flexRow(); + } + } +} + +.materials-side-modal-container { + padding: 0 24px; + + @include breakpoint("tablet", "max") { + @include safe-area-padding("left", 16px); + @include safe-area-padding("right", 16px); } } diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 75de95022..41dd6b3ce 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -14,6 +14,7 @@ import { import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; +import { selectCurrentUser } from "store/user/selectors"; import { CourseMainInfo } from "./main-info"; import { getCourseMatarials } from "./helpers"; @@ -25,6 +26,7 @@ const gridTemplateColumns = "1fr 248px"; export const CoursePage = () => { const { id } = useParams(); const course = useSelector((state) => selectCurrentCourse(state, id)); + const currentUser = useSelector(selectCurrentUser); const [isReviewsVisible, setIsReviewsVisible] = useState(false); const [isMaterialsVisible, setMaterialsVisible] = useState(false); @@ -68,7 +70,11 @@ export const CoursePage = () => {
- +
diff --git a/src/screens/news/news-list/filters-modal/index.jsx b/src/screens/news/news-list/filters-modal/index.jsx index e2b9771d3..846b20cd8 100644 --- a/src/screens/news/news-list/filters-modal/index.jsx +++ b/src/screens/news/news-list/filters-modal/index.jsx @@ -41,8 +41,7 @@ export const FiltersModal = ({ visible={visible} onClose={onClose} title="Add Filters" - actionTitle="Apply" - onActionClick={() => onApply(selected)} + actionProps={{ title: "Apply", onClick: () => onApply(selected) }} >
diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 381d06ecf..0cd5a42a8 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -130,3 +130,18 @@ bottom: $bottom; position: $position; } + +@mixin safe-area-padding($direction, $value) { + @if $direction == "top" { + padding-top: Max($value, env(safe-area-inset-top)); + } + @if $direction == "bottom" { + padding-bottom: Max($value, env(safe-area-inset-bottom)); + } + @if $direction == "left" { + padding-left: Max($value, env(safe-area-inset-left)); + } + @if $direction == "right" { + padding-right: Max($value, env(safe-area-inset-right)); + } +} From ae1e33c73c5cd1c82d722d0825695bf6ff12bc69 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 23 Apr 2022 04:18:12 +0300 Subject: [PATCH 16/34] implement action modal, refactoring --- src/common/modal-containers/action/index.jsx | 102 ++++++++++++++++++ .../modal-containers/action/styles.scss | 55 ++++++++++ src/common/modal-containers/index.js | 1 + src/common/modal/index.jsx | 12 --- src/common/modal/styles.scss | 11 -- src/components/courses/blocks/index.js | 1 - src/components/courses/index.js | 1 + .../courses/materials/add-modal/index.jsx | 7 ++ .../courses/materials/all-modal/index.jsx | 32 ++++++ .../courses/materials/all-modal/styles.scss | 10 ++ .../courses/materials/block/index.jsx | 52 +++++++++ .../courses/materials/block/styles.scss | 8 ++ .../meterials => materials/list}/config.js | 0 .../meterials => materials/list}/index.jsx | 54 +--------- .../meterials => materials/list}/styles.scss | 16 --- src/screens/courses/course/index.jsx | 2 +- src/scss/_colors.scss | 2 + src/scss/_mixins.scss | 6 ++ 18 files changed, 282 insertions(+), 90 deletions(-) create mode 100644 src/common/modal-containers/action/index.jsx create mode 100644 src/common/modal-containers/action/styles.scss create mode 100644 src/components/courses/materials/add-modal/index.jsx create mode 100644 src/components/courses/materials/all-modal/index.jsx create mode 100644 src/components/courses/materials/all-modal/styles.scss create mode 100644 src/components/courses/materials/block/index.jsx create mode 100644 src/components/courses/materials/block/styles.scss rename src/components/courses/{blocks/meterials => materials/list}/config.js (100%) rename src/components/courses/{blocks/meterials => materials/list}/index.jsx (57%) rename src/components/courses/{blocks/meterials => materials/list}/styles.scss (53%) diff --git a/src/common/modal-containers/action/index.jsx b/src/common/modal-containers/action/index.jsx new file mode 100644 index 000000000..6f3771a19 --- /dev/null +++ b/src/common/modal-containers/action/index.jsx @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { Formik, Form } from "formik"; + +import { Modal } from "common/modal"; +import { MobileUp, LaptopUp } from "common/responsive"; +import { IconButton } from "common/buttons/icon-button"; +import { ActionButton } from "common/buttons/action-button"; + +import "./styles.scss"; + +const defaultBtnProps = { + title: "Add", + onClick: null, + variant: "primary", +}; + +export const ActionModal = ({ + title, + visible, + onClose, + children, + actionButtonProps, +}) => { + if (!visible) return null; + + const actionBtnProps = useMemo(() => { + return { + ...defaultBtnProps, + ...(actionButtonProps && actionButtonProps), + }; + }, [actionButtonProps]); + + return ( + +
+
+ +

{title}

+ +
+ + +

{title}

+
+
+ +
+
{children && children}
+
+ +
+ + + + + +
+
+
+ ); +}; + +export const FormActionModal = ({ + title, + visible, + onClose, + onSubmit, + children, + initialValues, + validationSchema, + actionButtonProps, +}) => { + if (!visible) return null; + + return ( + + {() => ( + + + {children} + + + )} + + ); +}; diff --git a/src/common/modal-containers/action/styles.scss b/src/common/modal-containers/action/styles.scss new file mode 100644 index 000000000..53ebf8468 --- /dev/null +++ b/src/common/modal-containers/action/styles.scss @@ -0,0 +1,55 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.action-modal-container { + width: 80%; + padding: 40px; + max-height: 80%; + max-width: 608px; + border-radius: 4px; + background-color: $color-modal-bg; + + gap: $gap-big; + @include flexColumn(); + + .modal-header-container { + width: 100%; + height: 40px; + @include flexRow(center, center); + } + + .modal-content-container { + overflow-y: scroll; + + .modal-scroll-container { + gap: $gap-big; + @include flexColumn(); + } + } + + .modal-buttons-container { + width: 100%; + gap: $gap-medium; + @include flexRow(center, space-between); + } + + @include breakpoint("tablet", "max") { + width: 100%; + height: 100%; + gap: $gap-medium; + max-width: unset; + max-height: unset; + @include safe-area-padding("all", 16px); + + .modal-header-container { + height: 32px; + @include flexRow(center, space-between); + } + + .modal-buttons-container { + margin-top: auto; + justify-self: flex-end; + } + } +} diff --git a/src/common/modal-containers/index.js b/src/common/modal-containers/index.js index 49f451599..09e4d7adc 100644 --- a/src/common/modal-containers/index.js +++ b/src/common/modal-containers/index.js @@ -1,2 +1,3 @@ +export * from "./action"; export { CropperModal } from "./cropper"; export { DestructiveModalContainer } from "./destructive"; diff --git a/src/common/modal/index.jsx b/src/common/modal/index.jsx index 354020e59..780545db2 100644 --- a/src/common/modal/index.jsx +++ b/src/common/modal/index.jsx @@ -17,18 +17,6 @@ export const Modal = ({ visible, children, modalRef }) => { ); }; -export const MobileMenuModal = ({ visible, children, modalRef }) => { - if (!visible) return null; - - return ( - -
- {children} -
-
- ); -}; - export const CommonModal = ({ visible, title, onClose, children }) => { if (!visible) return null; diff --git a/src/common/modal/styles.scss b/src/common/modal/styles.scss index 351821ff0..6d88eb0b3 100644 --- a/src/common/modal/styles.scss +++ b/src/common/modal/styles.scss @@ -15,17 +15,6 @@ justify-content: center; } -.portal-mobile-modal-container { - backdrop-filter: blur(4px); - - left: 0; - right: 0; - top: 0; - bottom: 48px; // bottom-nav-height - position: fixed; - padding-top: 48px; -} - .common-modal-container { width: 80%; max-width: 608px; diff --git a/src/components/courses/blocks/index.js b/src/components/courses/blocks/index.js index 245fe5bb3..25d754e5a 100644 --- a/src/components/courses/blocks/index.js +++ b/src/components/courses/blocks/index.js @@ -1,4 +1,3 @@ -export * from "./meterials"; export * from "./members"; export * from "./lessons"; export * from "./reviews"; diff --git a/src/components/courses/index.js b/src/components/courses/index.js index 6b1b95a49..5f6189842 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -1 +1,2 @@ export * from "./course-list-item"; +export { MeterialsBlock } from "./materials/block"; diff --git a/src/components/courses/materials/add-modal/index.jsx b/src/components/courses/materials/add-modal/index.jsx new file mode 100644 index 000000000..728bf9a12 --- /dev/null +++ b/src/components/courses/materials/add-modal/index.jsx @@ -0,0 +1,7 @@ +import { ActionModal } from "common/modal-containers"; + +export const AddMaterialsModal = ({ visible, onClose }) => { + return ( + + ); +}; diff --git a/src/components/courses/materials/all-modal/index.jsx b/src/components/courses/materials/all-modal/index.jsx new file mode 100644 index 000000000..73c3e6d01 --- /dev/null +++ b/src/components/courses/materials/all-modal/index.jsx @@ -0,0 +1,32 @@ +import { SideModal } from "common/side-modal"; + +import { MaterialsList } from "../list"; + +import "./styles.scss"; + +const actionProps = { + icon: "plus", + variant: "secondary", + title: "Add Material", +}; + +export const AllMaterialsModal = ({ + visible, + onAdd, + onClose, + materials, + isEditMode, +}) => { + return ( + +
+ +
+
+ ); +}; diff --git a/src/components/courses/materials/all-modal/styles.scss b/src/components/courses/materials/all-modal/styles.scss new file mode 100644 index 000000000..2fb836e0f --- /dev/null +++ b/src/components/courses/materials/all-modal/styles.scss @@ -0,0 +1,10 @@ +@import "src/scss/mixins"; + +.materials-side-modal-container { + padding: 0 24px; + + @include breakpoint("tablet", "max") { + @include safe-area-padding("left", 16px); + @include safe-area-padding("right", 16px); + } +} diff --git a/src/components/courses/materials/block/index.jsx b/src/components/courses/materials/block/index.jsx new file mode 100644 index 000000000..b60b29e8c --- /dev/null +++ b/src/components/courses/materials/block/index.jsx @@ -0,0 +1,52 @@ +import { useState } from "react"; + +// TODO: REFACTOR +import { BlockHeader } from "../../blocks/header"; +import { EmptyBllock } from "../../blocks/empty-block"; + +import { MaterialsList } from "../list"; +import { AddMaterialsModal } from "../add-modal"; +import { AllMaterialsModal } from "../all-modal"; + +import "./styles.scss"; + +export const MeterialsBlock = ({ + materials = [], + isEditMode = false, + withAddButton = true, +}) => { + const [isAllVisible, setIsAllVisible] = useState(false); + const [isAddVisible, setIsAddVisible] = useState(false); + + return ( +
+ 0 ? () => setIsAllVisible(true) : null} + /> + + + + setIsAddVisible(true)} + isImageVisible={materials.length === 0} + /> + + setIsAddVisible(false)} + /> + + setIsAddVisible(true)} + onClose={() => setIsAllVisible(false)} + /> +
+ ); +}; diff --git a/src/components/courses/materials/block/styles.scss b/src/components/courses/materials/block/styles.scss new file mode 100644 index 000000000..7442a6c84 --- /dev/null +++ b/src/components/courses/materials/block/styles.scss @@ -0,0 +1,8 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.materials-block-container { + width: 100%; + gap: $gap-small; + @include flexColumn(flex-start, flex-start); +} diff --git a/src/components/courses/blocks/meterials/config.js b/src/components/courses/materials/list/config.js similarity index 100% rename from src/components/courses/blocks/meterials/config.js rename to src/components/courses/materials/list/config.js diff --git a/src/components/courses/blocks/meterials/index.jsx b/src/components/courses/materials/list/index.jsx similarity index 57% rename from src/components/courses/blocks/meterials/index.jsx rename to src/components/courses/materials/list/index.jsx index 4045173f7..5806667f2 100644 --- a/src/components/courses/blocks/meterials/index.jsx +++ b/src/components/courses/materials/list/index.jsx @@ -1,13 +1,9 @@ import { useState } from "react"; import { Icon } from "common/icon"; -import { SideModal } from "common/side-modal"; import { IconButton } from "common/buttons/icon-button"; import { DownloadButton } from "common/buttons/download-button"; -import { BlockHeader } from "../header"; -import { EmptyBllock } from "../empty-block"; - import { FileIconName } from "./config"; import "./styles.scss"; @@ -55,7 +51,11 @@ const Material = ({ material, isEditMode = true }) => { ); }; -const MaterialsList = ({ materials = [], maxLength, isEditMode = false }) => { +export const MaterialsList = ({ + maxLength, + materials = [], + isEditMode = false, +}) => { if (materials.length === 0) { return null; } @@ -74,47 +74,3 @@ const MaterialsList = ({ materials = [], maxLength, isEditMode = false }) => {
); }; - -export const MeterialsBlock = ({ - materials = [], - isEditMode = false, - withAddButton = true, -}) => { - const [isAllVisible, setIsAllVisible] = useState(false); - const [isAddVisible, setIsAddVisible] = useState(false); - - return ( -
- 0 ? () => setIsAllVisible(true) : null} - /> - - - - setIsAddVisible(true)} - isImageVisible={materials.length === 0} - /> - - setIsAllVisible(false)} - actionProps={{ - icon: "plus", - variant: "secondary", - title: "Add Material", - onClick: () => setIsAddVisible(true), - }} - > -
- -
-
-
- ); -}; diff --git a/src/components/courses/blocks/meterials/styles.scss b/src/components/courses/materials/list/styles.scss similarity index 53% rename from src/components/courses/blocks/meterials/styles.scss rename to src/components/courses/materials/list/styles.scss index 5d1692293..a8fa72a3d 100644 --- a/src/components/courses/blocks/meterials/styles.scss +++ b/src/components/courses/materials/list/styles.scss @@ -1,13 +1,6 @@ @import "src/scss/mixins"; -@import "src/scss/colors"; @import "src/scss/variables"; -.materials-block-container { - width: 100%; - gap: $gap-small; - @include flexColumn(flex-start, flex-start); -} - .materials-list-container { width: 100%; gap: $gap-medium; @@ -27,12 +20,3 @@ } } } - -.materials-side-modal-container { - padding: 0 24px; - - @include breakpoint("tablet", "max") { - @include safe-area-padding("left", 16px); - @include safe-area-padding("right", 16px); - } -} diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 41dd6b3ce..19fd0052c 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -9,8 +9,8 @@ import { MembersBlock, ReviewsBlock, LessonsBlock, - MeterialsBlock, } from "components/courses/blocks"; +import { MeterialsBlock } from "components/courses"; import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; diff --git a/src/scss/_colors.scss b/src/scss/_colors.scss index fcbe6220b..d11aa448f 100644 --- a/src/scss/_colors.scss +++ b/src/scss/_colors.scss @@ -41,3 +41,5 @@ $color-dark-white-active: rgba(186, 207, 200, 0.3); $color-dark-red: rgba(218, 52, 67, 0.1); $color-dark-red-hover: rgba(166, 40, 51, 0.25); $color-dark-red-active: rgba(140, 33, 43, 0.4); + +$color-modal-bg: #252827; diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 0cd5a42a8..af5083fb2 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -144,4 +144,10 @@ @if $direction == "right" { padding-right: Max($value, env(safe-area-inset-right)); } + @if $direction == "all" { + padding-top: Max($value, env(safe-area-inset-top)); + padding-left: Max($value, env(safe-area-inset-left)); + padding-right: Max($value, env(safe-area-inset-right)); + padding-bottom: Max($value, env(safe-area-inset-bottom)); + } } From 1ff3a3cf50abc7b585b49cc241a7a8312a9f8a56 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 25 Apr 2022 13:36:15 +0300 Subject: [PATCH 17/34] implement materials block --- src/common/buttons/download-button/index.jsx | 2 +- src/common/drop-zone/drag-drop/config.js | 17 ++++ src/common/drop-zone/drag-drop/index.jsx | 90 +++++++++++++++++++ src/common/drop-zone/drag-drop/styles.scss | 48 ++++++++++ src/common/drop-zone/index.js | 1 + .../courses/materials/add-modal/helpers.js | 15 ++++ .../courses/materials/add-modal/index.jsx | 50 ++++++++++- .../courses/materials/list/index.jsx | 53 ++++++++--- src/constants/enums.js | 6 ++ 9 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 src/common/drop-zone/drag-drop/config.js create mode 100644 src/common/drop-zone/drag-drop/index.jsx create mode 100644 src/common/drop-zone/drag-drop/styles.scss create mode 100644 src/common/drop-zone/index.js create mode 100644 src/components/courses/materials/add-modal/helpers.js diff --git a/src/common/buttons/download-button/index.jsx b/src/common/buttons/download-button/index.jsx index 298f28f0d..6f7e291fa 100644 --- a/src/common/buttons/download-button/index.jsx +++ b/src/common/buttons/download-button/index.jsx @@ -12,7 +12,7 @@ export const DownloadButton = ({ }) => { const icon = useMemo(() => { return isCompleted ? "checkmark" : "download"; - }, [isCompleted]); + }, [isCompleted, isDownloading]); return (
diff --git a/src/common/stars-rating/index.jsx b/src/common/stars-rating/index.jsx index ba7f5c46c..8d51f0d69 100644 --- a/src/common/stars-rating/index.jsx +++ b/src/common/stars-rating/index.jsx @@ -1,43 +1,63 @@ -import cx from "classnames"; +import { useField } from "formik"; -import { Icon } from "common/icon"; -import { useDeviceType } from "hooks"; -import { DeviceType } from "constants/enums"; +import { LaptopUp, MobileUp } from "common/responsive"; +import { IconButton } from "common/buttons/icon-button"; import "./styles.scss"; -export const StarsRating = ({ rate = 5, isFilledStarIcon = true }) => { - const device = useDeviceType(); - const isMobile = device === DeviceType.Tablet || device === DeviceType.Mobile; - +export const Stars = ({ count, onChangeValue, isFilled }) => { const indexes = [0, 1, 2, 3, 4]; - const icon = isFilledStarIcon ? "star" : "star-outline"; - - const containerClassName = cx("rating-container", { - [`rating-container-mobile`]: isMobile, - }); - - const className = (index) => - cx("rating-star-icon", { [`rating-star-icon-filled`]: index + 1 <= rate }); + const icon = isFilled ? "star" : "star-outline"; - if (isMobile) { - return ( -
- -

{rate}

-
- ); - } + const buttonVariant = (index) => { + return index + 1 <= count ? "star-selected" : "star"; + }; return ( -
+
{indexes.map((index) => ( - onChangeValue(index + 1) : null} /> ))}
); }; + +export const StarsRating = ({ rate = 5, isFilledStarIcon = true }) => { + return ( + <> + +
+ +

{rate}

+
+
+ + + + + + ); +}; + +export const StarsRatingField = ({ name, isFilled = true }) => { + const [field, meta, helpers] = useField(name); + + return ( + helpers.setValue(value)} + /> + ); +}; diff --git a/src/common/stars-rating/styles.scss b/src/common/stars-rating/styles.scss index 85d53434b..add9fd766 100644 --- a/src/common/stars-rating/styles.scss +++ b/src/common/stars-rating/styles.scss @@ -1,21 +1,12 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + .rating-container { - gap: 8px; - display: flex; - flex-direction: row; - align-items: center; + gap: $gap-smallest; + @include flexRow(center); &-mobile { gap: 4px; - } - - .rating-star-icon { - path { - fill: #676969; - } - &-filled { - path { - fill: #eaa533; - } - } + @include flexRow(center); } } diff --git a/src/common/text-area/styles.scss b/src/common/text-area/styles.scss index ec5dd3c18..79a1d32dd 100644 --- a/src/common/text-area/styles.scss +++ b/src/common/text-area/styles.scss @@ -1,8 +1,9 @@ +@import "src/scss/mixins"; @import "src/scss/variables"; .text-area-container { width: 100%; - padding: 16px 16px 0 16px; + padding: 16px; border-radius: 4px; background-color: rgba(20, 20, 20, 0.8); @@ -10,6 +11,7 @@ display: flex; flex-direction: column; + justify-content: space-between; &:hover { background-color: #1d2120; diff --git a/src/components/courses/common/empty-block/styles.scss b/src/components/courses/common/empty-block/styles.scss index 099434f0a..eb99184ca 100644 --- a/src/components/courses/common/empty-block/styles.scss +++ b/src/components/courses/common/empty-block/styles.scss @@ -4,12 +4,13 @@ .courses-empty-block-container { width: 100%; gap: $gap-medium; + overflow: hidden; @include flexColumn(center, center); img { - width: 100%; height: 100%; - object-fit: cover; + max-width: 100%; + object-fit: contain; } button { diff --git a/src/components/courses/reviews/add-review-modal/config.js b/src/components/courses/reviews/add-review-modal/config.js new file mode 100644 index 000000000..6994e5abe --- /dev/null +++ b/src/components/courses/reviews/add-review-modal/config.js @@ -0,0 +1,21 @@ +import * as yup from "yup"; + +export const model = { + review: { + name: "review", + }, + + rate: { + name: "rate", + }, +}; + +export const validationSchema = yup.object().shape({ + [model.review.name]: yup.string().required("Please leave a review"), + [model.rate.name]: yup.number().min(1).required(), +}); + +export const initialValues = { + [model.review.name]: "", + [model.rate.name]: 4, +}; diff --git a/src/components/courses/reviews/add-review-modal/index.jsx b/src/components/courses/reviews/add-review-modal/index.jsx index bb3cfe588..8b4eb087b 100644 --- a/src/components/courses/reviews/add-review-modal/index.jsx +++ b/src/components/courses/reviews/add-review-modal/index.jsx @@ -1,3 +1,63 @@ -export const AddReviewModal = () => { - return null; +import { Icon } from "common/icon"; +import { SideModal } from "common/side-modal"; +import { TextAreaField } from "common/text-area"; +import { StarsRatingField } from "common/stars-rating"; + +import { model, validationSchema, initialValues } from "./config"; + +import "./styles.scss"; + +const Section = ({ icon, label, children }) => { + return ( +
+
+ +
{label}
+
+ + {children} +
+ ); +}; + +export const AddReviewModal = ({ visible, onClose }) => { + const handleFormSubmit = () => { + onClose(); + }; + + return ( + +
+
+ +
+ +
+ +
+
+
+ ); }; diff --git a/src/components/courses/reviews/add-review-modal/styles.scss b/src/components/courses/reviews/add-review-modal/styles.scss new file mode 100644 index 000000000..cebc156c1 --- /dev/null +++ b/src/components/courses/reviews/add-review-modal/styles.scss @@ -0,0 +1,31 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.add-review-side-modal-container { + padding: 0 24px; + gap: $gap-medium; + @include flexColumn(); + + @include breakpoint("tablet", "max") { + @include safe-area-padding("left", 16px); + @include safe-area-padding("right", 16px); + } + + .reviews-section-container { + width: 100%; + gap: $gap-small; + @include flexColumn(); + + .label-container { + gap: $gap-smallest; + @include flexRow(center, flex-start); + + @include setSvgColor($color-grey); + + h5 { + color: $color-grey; + } + } + } +} diff --git a/src/scss/styles.scss b/src/scss/styles.scss index baa39a9aa..58623bfce 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -113,6 +113,11 @@ h4 { font: 16px/24px $font-semi-bold; } +// p { +// color: #eeefef; +// font: 16px/24px $font-regular; +// } + h5 { color: #eeefef; font: 16px/24px $font-regular; From 73a5a63f109315497bf9d82cef71f88416af746d Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 29 Apr 2022 05:20:49 +0300 Subject: [PATCH 21/34] implement course progress bar component --- package.json | 1 + src/assets/icons/checkmark-round.svg | 4 + src/common/icon/index.jsx | 5 + src/common/progress/circular/styles.scss | 16 +++ src/common/progress/index.js | 1 + src/components/courses/blocks/index.js | 1 - .../courses/blocks/lessons/index.jsx | 12 -- src/components/courses/common/index.js | 1 + .../courses/common/progress-bar/config.js | 18 +++ .../courses/common/progress-bar/index.jsx | 48 ++++++++ .../courses/common/progress-bar/styles.scss | 105 ++++++++++++++++++ src/components/courses/index.js | 1 + src/components/courses/lessons/index.jsx | 25 +++++ .../courses/{blocks => }/lessons/styles.scss | 6 + src/screens/courses/course/index.jsx | 4 +- 15 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 src/assets/icons/checkmark-round.svg delete mode 100644 src/components/courses/blocks/lessons/index.jsx create mode 100644 src/components/courses/common/progress-bar/config.js create mode 100644 src/components/courses/common/progress-bar/index.jsx create mode 100644 src/components/courses/common/progress-bar/styles.scss create mode 100644 src/components/courses/lessons/index.jsx rename src/components/courses/{blocks => }/lessons/styles.scss (71%) diff --git a/package.json b/package.json index d14b5164b..e7cd96ad6 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "parcel": "^2.0.0-rc.0", "react": "^17.0.2", "react-alert": "^7.0.3", + "react-circular-progressbar": "^2.0.4", "react-currency-input-field": "^3.6.4", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", diff --git a/src/assets/icons/checkmark-round.svg b/src/assets/icons/checkmark-round.svg new file mode 100644 index 000000000..6013d12fa --- /dev/null +++ b/src/assets/icons/checkmark-round.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/common/icon/index.jsx b/src/common/icon/index.jsx index 6d1beda46..e55f9fcc2 100644 --- a/src/common/icon/index.jsx +++ b/src/common/icon/index.jsx @@ -56,6 +56,7 @@ import { ReactComponent as EditIcon } from "assets/icons/edit.svg"; import { ReactComponent as StarOutlineIcon } from "assets/icons/star-outline.svg"; import { ReactComponent as StarIcon } from "assets/icons/star.svg"; import { ReactComponent as DownloadIcon } from "assets/icons/download.svg"; +import { ReactComponent as CheckmarkRoundIcon } from "assets/icons/checkmark-round.svg"; const getIcon = (iconName) => { switch (iconName) { @@ -83,6 +84,10 @@ const getIcon = (iconName) => { case "checkmark": return ; + case "checkmark-round": { + return ; + } + case "chevron-left": return ; diff --git a/src/common/progress/circular/styles.scss b/src/common/progress/circular/styles.scss index 94f06df3d..1a3170857 100644 --- a/src/common/progress/circular/styles.scss +++ b/src/common/progress/circular/styles.scss @@ -14,6 +14,22 @@ .outter-circle { stroke: $color-green; } + .outter-circle { + fill: red; + } + } + + &-red { + .outter-circle { + stroke: $color-red; + fill: yellow; + } + } + + &-grey { + .outter-circle { + stroke: #555550; + } } &-infinite { diff --git a/src/common/progress/index.js b/src/common/progress/index.js index e69de29bb..91836e37d 100644 --- a/src/common/progress/index.js +++ b/src/common/progress/index.js @@ -0,0 +1 @@ +export { CircularProgress } from "./circular"; diff --git a/src/components/courses/blocks/index.js b/src/components/courses/blocks/index.js index 1d95929ce..b966c6810 100644 --- a/src/components/courses/blocks/index.js +++ b/src/components/courses/blocks/index.js @@ -1,2 +1 @@ export * from "./members"; -export * from "./lessons"; diff --git a/src/components/courses/blocks/lessons/index.jsx b/src/components/courses/blocks/lessons/index.jsx deleted file mode 100644 index 9b24b0444..000000000 --- a/src/components/courses/blocks/lessons/index.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { EmptyBllock, BlockHeader } from "components/courses/common"; - -import "./styles.scss"; - -export const LessonsBlock = ({ lessons = [] }) => { - return ( -
- - {lessons.length === 0 && } -
- ); -}; diff --git a/src/components/courses/common/index.js b/src/components/courses/common/index.js index e9af7e280..f8ba7d4b3 100644 --- a/src/components/courses/common/index.js +++ b/src/components/courses/common/index.js @@ -1,2 +1,3 @@ export { EmptyBllock } from "./empty-block"; export { BlockHeader } from "./block-header"; +export { CourseProgressBar } from "./progress-bar"; diff --git a/src/components/courses/common/progress-bar/config.js b/src/components/courses/common/progress-bar/config.js new file mode 100644 index 000000000..6f79bbfa8 --- /dev/null +++ b/src/components/courses/common/progress-bar/config.js @@ -0,0 +1,18 @@ +export const Progress = { + None: "None", + InProgress: "InProgress", + Completed: "Completed", +}; + +export const Variant = { + [Progress.None]: "grey", + [Progress.InProgress]: "red", + [Progress.Completed]: "green", +}; + +export const getProgressType = (progress = 0) => { + if (!progress || progress === 0) return Progress.None; + return progress > 0 && progress < 50 + ? Progress.InProgress + : Progress.Completed; +}; diff --git a/src/components/courses/common/progress-bar/index.jsx b/src/components/courses/common/progress-bar/index.jsx new file mode 100644 index 000000000..6842b99f6 --- /dev/null +++ b/src/components/courses/common/progress-bar/index.jsx @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import cx from "classnames"; +import { CircularProgressbar } from "react-circular-progressbar"; + +import { Icon } from "common/icon"; + +import { getProgressType, Variant, Progress } from "./config"; + +import "./styles.scss"; + +export const CourseProgressBar = ({ + index, + progress = 0, + withIndex = false, + withProgress = false, +}) => { + const progressType = useMemo(() => { + return getProgressType(progress); + }, [progress]); + + const className = useMemo(() => { + const classname = "course-progress-bar-container"; + return cx(classname, { [`${classname}-${Variant[progressType]}`]: true }); + }, [progressType]); + + const isProgressVisible = useMemo(() => { + return progress !== 100 && withProgress && progress; + }, [withProgress, progress]); + + const isCompleteIconVisible = useMemo(() => { + return progressType === Progress.Completed && progress === 100; + }, [progress, progressType]); + + const isIndexVisible = useMemo(() => { + return withIndex && index && progressType !== Progress.Completed; + }, [index, withIndex, progressType]); + + return ( +
+ +
+ {isIndexVisible &&

{index || 1}

} + {isProgressVisible &&

{`${progress || 0}%`}

} + {isCompleteIconVisible && } +
+
+ ); +}; diff --git a/src/components/courses/common/progress-bar/styles.scss b/src/components/courses/common/progress-bar/styles.scss new file mode 100644 index 000000000..d7b4214ba --- /dev/null +++ b/src/components/courses/common/progress-bar/styles.scss @@ -0,0 +1,105 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.course-progress-bar-container { + width: 72px; + height: 72px; + position: relative; + border-radius: 100%; + backdrop-filter: blur(12px); + + .CircularProgressbar-trail { + stroke-width: 4px; + } + .CircularProgressbar-path { + stroke-width: 4px; + } + + .inner-circle { + top: 50%; + left: 50%; + position: absolute; + transform: translateX(-50%) translateY(-50%); + + width: 56px; + height: 56px; + border-radius: 100%; + + display: flex; + align-items: center; + justify-content: center; + + .checkmark-round-icon { + width: 32px; + height: 32px; + path { + fill: $color-black; + } + } + } + + &-green { + .inner-circle { + background-color: $color-green; + h4 { + color: $color-black-active; + } + } + .CircularProgressbar-trail { + stroke: rgba($color: $color-green, $alpha: 0.4); + } + .CircularProgressbar-path { + stroke: $color-green; + } + } + + &-red { + .inner-circle { + background-color: $color-red; + h4 { + color: $color-white; + } + } + .CircularProgressbar-trail { + stroke: rgba($color: $color-red, $alpha: 0.4); + } + .CircularProgressbar-path { + stroke: $color-red; + } + } + + &-grey { + .inner-circle { + background-color: #555550; + } + .CircularProgressbar-trail { + stroke: rgba($color: #555550, $alpha: 1); + } + .CircularProgressbar-path { + stroke: #555550; + } + } + + @include breakpoint("mobile", "minMax") { + width: 40px; + height: 40px; + + .CircularProgressbar-trail { + stroke-width: 8px; + } + .CircularProgressbar-path { + stroke-width: 8px; + } + + .inner-circle { + width: 24px; + height: 24px; + + .checkmark-round-icon { + width: 16px; + height: 16px; + } + } + } +} diff --git a/src/components/courses/index.js b/src/components/courses/index.js index a1b4414db..ddc2d84b5 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -1,3 +1,4 @@ export * from "./course-list-item"; export { ReviewsBlock } from "./reviews/block"; export { MeterialsBlock } from "./materials/block"; +export { LessonsBlock } from "./lessons"; diff --git a/src/components/courses/lessons/index.jsx b/src/components/courses/lessons/index.jsx new file mode 100644 index 000000000..0c4058923 --- /dev/null +++ b/src/components/courses/lessons/index.jsx @@ -0,0 +1,25 @@ +import { + // EmptyBllock, + BlockHeader, + CourseProgressBar, +} from "components/courses/common"; + +import "./styles.scss"; + +const LessonListItem = () => { + return ( +
+ +
+ ); +}; + +export const LessonsBlock = ({ lessons = [] }) => { + return ( +
+ + {/* {lessons.length === 0 && } */} + +
+ ); +}; diff --git a/src/components/courses/blocks/lessons/styles.scss b/src/components/courses/lessons/styles.scss similarity index 71% rename from src/components/courses/blocks/lessons/styles.scss rename to src/components/courses/lessons/styles.scss index 9aeda923b..de595e7e2 100644 --- a/src/components/courses/blocks/lessons/styles.scss +++ b/src/components/courses/lessons/styles.scss @@ -11,3 +11,9 @@ gap: $gap-big; } } + +.lesson-list-item-container { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index fbef1ab2e..7c180e23e 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -4,8 +4,8 @@ import { useParams } from "react-router-dom"; import { TwoColumnsGrid } from "common/grids"; import { ContentBlocks } from "common/content"; import { ActionButton } from "common/buttons/action-button"; -import { MembersBlock, LessonsBlock } from "components/courses/blocks"; -import { MeterialsBlock, ReviewsBlock } from "components/courses"; +import { MembersBlock } from "components/courses/blocks"; +import { MeterialsBlock, ReviewsBlock, LessonsBlock } from "components/courses"; import { DashboardLayout } from "layout/dashboard"; import { selectCurrentCourse } from "store/courses"; From 4128ab8e712cb745fb9721503ff2b4d3c13f026b Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 30 Apr 2022 02:33:55 +0300 Subject: [PATCH 22/34] implement course lesson item component --- src/common/avatar/styles.scss | 2 +- .../courses/common/progress-bar/config.js | 12 +- .../courses/common/progress-bar/index.jsx | 64 +++++- .../courses/common/progress-bar/styles.scss | 194 ++++++++++++------ src/components/courses/lessons/index.jsx | 83 +++++++- src/components/courses/lessons/styles.scss | 51 ++++- src/screens/courses/course/helpers.js | 5 + src/screens/courses/course/index.jsx | 8 +- src/scss/_colors.scss | 1 + src/utils/mocked/lessons.js | 57 +++++ 10 files changed, 383 insertions(+), 94 deletions(-) create mode 100644 src/utils/mocked/lessons.js diff --git a/src/common/avatar/styles.scss b/src/common/avatar/styles.scss index 93a405a50..0f205cb82 100644 --- a/src/common/avatar/styles.scss +++ b/src/common/avatar/styles.scss @@ -1,7 +1,7 @@ .avatar-container { width: 32px; height: 32px; - border-radius: 32px; + border-radius: 100%; background-color: #f8ebdf; user-select: none; diff --git a/src/components/courses/common/progress-bar/config.js b/src/components/courses/common/progress-bar/config.js index 6f79bbfa8..5cdf082f3 100644 --- a/src/components/courses/common/progress-bar/config.js +++ b/src/components/courses/common/progress-bar/config.js @@ -1,18 +1,22 @@ export const Progress = { None: "None", - InProgress: "InProgress", Completed: "Completed", + InProgress: "InProgress", + PartlyCompleted: "PartlyCompleted", }; export const Variant = { [Progress.None]: "grey", [Progress.InProgress]: "red", [Progress.Completed]: "green", + [Progress.PartlyCompleted]: "green", }; export const getProgressType = (progress = 0) => { if (!progress || progress === 0) return Progress.None; - return progress > 0 && progress < 50 - ? Progress.InProgress - : Progress.Completed; + + if (progress > 0 && progress < 50) return Progress.InProgress; + if (progress > 50 && progress < 100) return Progress.PartlyCompleted; + + return Progress.Completed; }; diff --git a/src/components/courses/common/progress-bar/index.jsx b/src/components/courses/common/progress-bar/index.jsx index 6842b99f6..52a3ba6a5 100644 --- a/src/components/courses/common/progress-bar/index.jsx +++ b/src/components/courses/common/progress-bar/index.jsx @@ -8,11 +8,41 @@ import { getProgressType, Variant, Progress } from "./config"; import "./styles.scss"; +const ProgressLine = ({ isFirst, isLast, isCompleted, isPreviusCompleted }) => { + const className = "progress-line"; + + const variant = useMemo(() => { + if (isFirst) { + return `transparent-${isCompleted ? "green" : "grey"}`; + } + + if (isLast) { + return `${isPreviusCompleted ? "green" : "grey"}-transparent`; + } + + return `${isPreviusCompleted ? "green" : "grey"}-${ + isCompleted ? "green" : "grey" + }`; + }, [isFirst, isLast, isCompleted, isPreviusCompleted]); + + const progressClassName = useMemo( + () => cx(className, { [`${className}-${variant}`]: true }), + [variant] + ); + + return
; +}; + export const CourseProgressBar = ({ - index, + isLast, + isFirst, + index = 0, + isCompleted, progress = 0, withIndex = false, withProgress = false, + withProgressLine = false, + isPreviusCompleted = false, }) => { const progressType = useMemo(() => { return getProgressType(progress); @@ -27,21 +57,33 @@ export const CourseProgressBar = ({ return progress !== 100 && withProgress && progress; }, [withProgress, progress]); - const isCompleteIconVisible = useMemo(() => { - return progressType === Progress.Completed && progress === 100; - }, [progress, progressType]); - const isIndexVisible = useMemo(() => { - return withIndex && index && progressType !== Progress.Completed; + return withIndex && index; }, [index, withIndex, progressType]); + const isCompleteIconVisible = useMemo(() => { + return progressType === Progress.Completed && !isIndexVisible; + }, [progress, progressType, isIndexVisible]); + return (
- -
- {isIndexVisible &&

{index || 1}

} - {isProgressVisible &&

{`${progress || 0}%`}

} - {isCompleteIconVisible && } + {withProgressLine && ( + + )} + +
+ + +
+ {isIndexVisible &&

{index || 1}

} + {isProgressVisible &&

{`${progress || 0}%`}

} + {isCompleteIconVisible && } +
); diff --git a/src/components/courses/common/progress-bar/styles.scss b/src/components/courses/common/progress-bar/styles.scss index d7b4214ba..8f1d99254 100644 --- a/src/components/courses/common/progress-bar/styles.scss +++ b/src/components/courses/common/progress-bar/styles.scss @@ -2,103 +2,163 @@ @import "src/scss/colors"; @import "src/scss/variables"; +@mixin setProgress($firstColor, $secondColor) { + background: linear-gradient(180deg, $firstColor 50%, $secondColor 50%); +} + .course-progress-bar-container { - width: 72px; - height: 72px; position: relative; - border-radius: 100%; - backdrop-filter: blur(12px); + height: 100%; + @include flexColumn(center, center); - .CircularProgressbar-trail { - stroke-width: 4px; - } - .CircularProgressbar-path { - stroke-width: 4px; - } + .progress-line { + width: 4px; + @include position(absolute, 0, auto, 0, 50%); - .inner-circle { - top: 50%; - left: 50%; - position: absolute; - transform: translateX(-50%) translateY(-50%); + &-transparent-green { + @include setProgress(transparent, $color-green); + } - width: 56px; - height: 56px; - border-radius: 100%; + &-transparent-grey { + @include setProgress(transparent, $color-progress-line); + } + + &-green-transparent { + @include setProgress($color-green, transparent); + } - display: flex; - align-items: center; - justify-content: center; + &-grey-transparent { + @include setProgress($color-progress-line, transparent); + } - .checkmark-round-icon { - width: 32px; - height: 32px; - path { - fill: $color-black; - } + &-green-green { + @include setProgress($color-green, $color-green); } - } - &-green { - .inner-circle { - background-color: $color-green; - h4 { - color: $color-black-active; - } + &-green-grey { + @include setProgress($color-green, $color-progress-line); } + + &-grey-grey { + @include setProgress($color-progress-line, $color-progress-line); + } + + &-grey-green { + @include setProgress($color-progress-line, $color-green); + } + } + + .course-progress-bar { + width: 72px; + height: 72px; + flex-shrink: 0; + position: relative; + border-radius: 100%; + backdrop-filter: blur(4px); + + z-index: 10; + .CircularProgressbar-trail { - stroke: rgba($color: $color-green, $alpha: 0.4); + stroke-width: 4px; } .CircularProgressbar-path { - stroke: $color-green; + stroke-width: 4px; } - } - &-red { .inner-circle { - background-color: $color-red; - h4 { - color: $color-white; + top: 50%; + left: 50%; + position: absolute; + transform: translateX(-50%) translateY(-50%); + + width: 56px; + height: 56px; + border-radius: 100%; + + display: flex; + align-items: center; + justify-content: center; + + .checkmark-round-icon { + width: 32px; + height: 32px; + path { + fill: $color-black; + } } } - .CircularProgressbar-trail { - stroke: rgba($color: $color-red, $alpha: 0.4); + } + + &-green { + .course-progress-bar { + .inner-circle { + background-color: $color-green; + h4 { + color: $color-black-active; + } + } + .CircularProgressbar-trail { + stroke: rgba($color: $color-green, $alpha: 0.4); + } + .CircularProgressbar-path { + stroke: $color-green; + } } - .CircularProgressbar-path { - stroke: $color-red; + } + + &-red { + .course-progress-bar { + .inner-circle { + background-color: $color-red; + h4 { + color: $color-white; + } + } + .CircularProgressbar-trail { + stroke: rgba($color: $color-red, $alpha: 0.4); + } + .CircularProgressbar-path { + stroke: $color-red; + } } } &-grey { - .inner-circle { - background-color: #555550; - } - .CircularProgressbar-trail { - stroke: rgba($color: #555550, $alpha: 1); - } - .CircularProgressbar-path { - stroke: #555550; + .course-progress-bar { + .inner-circle { + background-color: #555550; + } + .CircularProgressbar-trail { + stroke: rgba($color: #555550, $alpha: 1); + } + .CircularProgressbar-path { + stroke: #555550; + } } } @include breakpoint("mobile", "minMax") { - width: 40px; - height: 40px; + @include flexColumn(flex-start, flex-start); - .CircularProgressbar-trail { - stroke-width: 8px; - } - .CircularProgressbar-path { - stroke-width: 8px; - } + .course-progress-bar { + width: 40px; + height: 40px; - .inner-circle { - width: 24px; - height: 24px; + .CircularProgressbar-trail { + stroke-width: 8px; + } + .CircularProgressbar-path { + stroke-width: 8px; + } - .checkmark-round-icon { - width: 16px; - height: 16px; + .inner-circle { + width: 24px; + height: 24px; + + .checkmark-round-icon { + width: 16px; + height: 16px; + } } } } diff --git a/src/components/courses/lessons/index.jsx b/src/components/courses/lessons/index.jsx index 0c4058923..801cb8bf4 100644 --- a/src/components/courses/lessons/index.jsx +++ b/src/components/courses/lessons/index.jsx @@ -1,15 +1,86 @@ +import { useMemo } from "react"; + +import { Avatar } from "common/avatar"; +import { Mobile, LaptopUp } from "common/responsive"; import { - // EmptyBllock, + EmptyBllock, BlockHeader, CourseProgressBar, } from "components/courses/common"; import "./styles.scss"; -const LessonListItem = () => { +// TODO: Implement Mobile Version; +// TODO: Refactor; + +const Lesson = ({ thumbnail, title, subtitle }) => { return ( -
- +
+ + + + + +
+

{title}

+
{subtitle}
+
+
+ + +
+ +

{title}

+
+ +
+
{subtitle}
+
+
+
+ ); +}; + +const ProgressList = ({ lessons = [] }) => { + const data = useMemo(() => { + return lessons.reduce((prev, current, index, array) => { + const prevItem = prev[index - 1] || null; + + return [ + ...prev, + { + ...current, + isFirstItem: index === 0, + isCompleted: current.progress > 40, + isLastItem: index === array.length - 1, + isPreviusCompleted: prevItem ? prevItem.progress > 40 : false, + }, + ]; + }, []); + }, [lessons]); + + return ( +
+ {data.map((lesson, index) => ( +
+ + + +
+ ))}
); }; @@ -18,8 +89,8 @@ export const LessonsBlock = ({ lessons = [] }) => { return (
- {/* {lessons.length === 0 && } */} - + {lessons.length === 0 && } + {lessons.length > 0 && }
); }; diff --git a/src/components/courses/lessons/styles.scss b/src/components/courses/lessons/styles.scss index de595e7e2..dfa77c841 100644 --- a/src/components/courses/lessons/styles.scss +++ b/src/components/courses/lessons/styles.scss @@ -12,8 +12,53 @@ } } +.progress-list-container { + width: 100%; + margin-top: -$gap-big; + @include flexColumn(flex-start, flex-start); +} + .lesson-list-item-container { - display: flex; - align-items: center; - justify-content: center; + gap: 40px; + height: calc(120px + #{$gap-big}); + @include flexRow(center, center); + + .lesson-item { + gap: $gap-big; + @include flexRow(flex-start, flex-start); + + .avatar-container { + width: 120px; + height: 120px; + } + + .info-block { + gap: $gap-small; + @include flexColumn(flex-start, flex-start); + + h5 { + @include setTextLines(3); + } + } + + .mobile-row-container { + gap: $gap-small; + @include flexRow(center, flex-start); + + .avatar-container { + width: 48px; + height: 48px; + } + } + } + + @include breakpoint("mobile", "minMax") { + @include flexRow(flex-start, flex-start); + + .lesson-item { + gap: $gap-small; + height: calc(96px + #{$gap-medium}); + @include flexColumn(flex-start, center); + } + } } diff --git a/src/screens/courses/course/helpers.js b/src/screens/courses/course/helpers.js index 144f02c86..85b58c971 100644 --- a/src/screens/courses/course/helpers.js +++ b/src/screens/courses/course/helpers.js @@ -1,4 +1,5 @@ import { mockedReviews } from "utils/mocked/reviews"; +import { mockedLessons } from "utils/mocked/lessons"; import { mockedMaterials } from "utils/mocked/materials"; export const getCourseMatarials = (courseId) => { @@ -10,3 +11,7 @@ export const getCourseReviews = (courseId) => { if (!courseId) return []; return mockedReviews[courseId]?.reviews || []; }; + +export const getCourseLessons = (courseId) => { + return mockedLessons[courseId]?.lessons || []; +}; diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 7c180e23e..574eed630 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -12,7 +12,11 @@ import { selectCurrentCourse } from "store/courses"; // import { selectCurrentUser } from "store/user/selectors"; import { CourseMainInfo } from "./main-info"; -import { getCourseMatarials, getCourseReviews } from "./helpers"; +import { + getCourseMatarials, + getCourseReviews, + getCourseLessons, +} from "./helpers"; import "./styles.scss"; @@ -59,7 +63,7 @@ export const CoursePage = () => { /> - +
Date: Sun, 1 May 2022 21:01:34 +0300 Subject: [PATCH 23/34] implement progress list component and progress list component --- src/common/content/blocks/index.jsx | 4 +- src/common/grids/two-columns-grid/index.jsx | 4 +- src/common/modal-containers/action/index.jsx | 6 +- src/common/responsive/index.jsx | 6 +- src/common/stars-rating/index.jsx | 6 +- .../courses/common/empty-block/index.jsx | 4 +- src/components/courses/common/index.js | 1 + .../courses/common/progress-bar/styles.scss | 9 +- .../courses/common/progress-list/index.jsx | 26 ++++++ .../courses/common/progress-list/styles.scss | 19 +++++ src/components/courses/lessons/index.jsx | 84 +++++-------------- .../courses/lessons/list-item/index.jsx | 32 +++++++ .../courses/lessons/list-item/styles.scss | 37 ++++++++ src/components/courses/lessons/styles.scss | 51 ----------- src/hooks/useResponsive.js | 14 +++- .../courses/course/main-info/index.jsx | 14 ++-- 16 files changed, 174 insertions(+), 143 deletions(-) create mode 100644 src/components/courses/common/progress-list/index.jsx create mode 100644 src/components/courses/common/progress-list/styles.scss create mode 100644 src/components/courses/lessons/list-item/index.jsx create mode 100644 src/components/courses/lessons/list-item/styles.scss diff --git a/src/common/content/blocks/index.jsx b/src/common/content/blocks/index.jsx index 36d2c5349..60e9f3b8b 100644 --- a/src/common/content/blocks/index.jsx +++ b/src/common/content/blocks/index.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from "react"; -import { MobileUp, LaptopUp } from "common/responsive"; +import { TabletUp, LaptopUp } from "common/responsive"; import { isFileInstanse } from "utils/parsers/file"; @@ -10,7 +10,7 @@ export const TextBlock = ({ title, text }) => { return (
{title &&

{title}

}
- {title &&

{title}

}
+ {title &&

{title}

}
{text &&
{text}
}
); diff --git a/src/common/grids/two-columns-grid/index.jsx b/src/common/grids/two-columns-grid/index.jsx index 518ba3fc3..9309773cf 100644 --- a/src/common/grids/two-columns-grid/index.jsx +++ b/src/common/grids/two-columns-grid/index.jsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import cx from "classnames"; -import { isMobileUp } from "hooks/useResponsive"; +import { isTabletUp } from "hooks/useResponsive"; import "./styles.scss"; @@ -10,7 +10,7 @@ export const TwoColumnsGrid = ({ templateColumns, reverseMobile = false, }) => { - const isTablet = isMobileUp(); + const isTablet = isTabletUp(); const grid = useMemo( () => (isTablet ? "1fr" : templateColumns), diff --git a/src/common/modal-containers/action/index.jsx b/src/common/modal-containers/action/index.jsx index 6f3771a19..58c8ba2e0 100644 --- a/src/common/modal-containers/action/index.jsx +++ b/src/common/modal-containers/action/index.jsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Formik, Form } from "formik"; import { Modal } from "common/modal"; -import { MobileUp, LaptopUp } from "common/responsive"; +import { TabletUp, LaptopUp } from "common/responsive"; import { IconButton } from "common/buttons/icon-button"; import { ActionButton } from "common/buttons/action-button"; @@ -34,10 +34,10 @@ export const ActionModal = ({
- +

{title}

-
+

{title}

diff --git a/src/common/responsive/index.jsx b/src/common/responsive/index.jsx index b79c2a480..dd218c834 100644 --- a/src/common/responsive/index.jsx +++ b/src/common/responsive/index.jsx @@ -4,7 +4,7 @@ import { isTablet, isDesktop, isLaptopUp, - isMobileUp, + isTabletUp, } from "hooks/useResponsive"; export const Desktop = ({ children }) => { @@ -23,8 +23,8 @@ export const Mobile = ({ children }) => { return isMobile() ? <>{children} : null; }; -export const MobileUp = ({ children }) => { - return isMobileUp() ? <>{children} : null; +export const TabletUp = ({ children }) => { + return isTabletUp() ? <>{children} : null; }; export const LaptopUp = ({ children }) => { diff --git a/src/common/stars-rating/index.jsx b/src/common/stars-rating/index.jsx index 8d51f0d69..a9826a2a8 100644 --- a/src/common/stars-rating/index.jsx +++ b/src/common/stars-rating/index.jsx @@ -1,6 +1,6 @@ import { useField } from "formik"; -import { LaptopUp, MobileUp } from "common/responsive"; +import { LaptopUp, TabletUp } from "common/responsive"; import { IconButton } from "common/buttons/icon-button"; import "./styles.scss"; @@ -31,7 +31,7 @@ export const Stars = ({ count, onChangeValue, isFilled }) => { export const StarsRating = ({ rate = 5, isFilledStarIcon = true }) => { return ( <> - +
{ />

{rate}

-
+ diff --git a/src/components/courses/common/empty-block/index.jsx b/src/components/courses/common/empty-block/index.jsx index f30b0080c..bb7747ed8 100644 --- a/src/components/courses/common/empty-block/index.jsx +++ b/src/components/courses/common/empty-block/index.jsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { isMobileUp } from "hooks/useResponsive"; +import { isTabletUp } from "hooks/useResponsive"; import { ActionButton } from "common/buttons/action-button"; import * as Config from "./config"; @@ -16,7 +16,7 @@ export const EmptyBllock = ({ }) => { if (!isImageVisible && !isAddButtonVisible) return null; - const isMobile = isMobileUp(); + const isMobile = isTabletUp(); const buttonTitle = Config.ButtonTitle[variant]; const buttonVariant = Config.ButtonVariant[variant]; diff --git a/src/components/courses/common/index.js b/src/components/courses/common/index.js index f8ba7d4b3..a8de1057a 100644 --- a/src/components/courses/common/index.js +++ b/src/components/courses/common/index.js @@ -1,3 +1,4 @@ export { EmptyBllock } from "./empty-block"; export { BlockHeader } from "./block-header"; +export { ProgressList } from "./progress-list"; export { CourseProgressBar } from "./progress-bar"; diff --git a/src/components/courses/common/progress-bar/styles.scss b/src/components/courses/common/progress-bar/styles.scss index 8f1d99254..3e47fc93e 100644 --- a/src/components/courses/common/progress-bar/styles.scss +++ b/src/components/courses/common/progress-bar/styles.scss @@ -4,12 +4,17 @@ @mixin setProgress($firstColor, $secondColor) { background: linear-gradient(180deg, $firstColor 50%, $secondColor 50%); + + @include breakpoint("tablet", "max") { + background: $secondColor; + } } .course-progress-bar-container { position: relative; - height: 100%; + // height: 100%; @include flexColumn(center, center); + align-self: stretch; .progress-line { width: 4px; @@ -137,7 +142,7 @@ } } - @include breakpoint("mobile", "minMax") { + @include breakpoint("tablet", "max") { @include flexColumn(flex-start, flex-start); .course-progress-bar { diff --git a/src/components/courses/common/progress-list/index.jsx b/src/components/courses/common/progress-list/index.jsx new file mode 100644 index 000000000..be0dfea7d --- /dev/null +++ b/src/components/courses/common/progress-list/index.jsx @@ -0,0 +1,26 @@ +import { CourseProgressBar } from "../progress-bar"; + +import "./styles.scss"; + +export const ProgressList = ({ list = [], render }) => { + return ( +
+ {list.map((item, index) => ( +
+ + + {render && render(item, index)} +
+ ))} +
+ ); +}; diff --git a/src/components/courses/common/progress-list/styles.scss b/src/components/courses/common/progress-list/styles.scss new file mode 100644 index 000000000..aa3154499 --- /dev/null +++ b/src/components/courses/common/progress-list/styles.scss @@ -0,0 +1,19 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.progress-list-container { + width: 100%; + margin-top: -$gap-big; + @include flexColumn(flex-start, flex-start); + + .list-item-container { + gap: $gap-big; + @include flexRow(center, center); + } + + @include breakpoint("tablet", "max") { + .list-item-container { + gap: $gap-small; + } + } +} diff --git a/src/components/courses/lessons/index.jsx b/src/components/courses/lessons/index.jsx index 801cb8bf4..8629d49b1 100644 --- a/src/components/courses/lessons/index.jsx +++ b/src/components/courses/lessons/index.jsx @@ -1,48 +1,19 @@ import { useMemo } from "react"; -import { Avatar } from "common/avatar"; -import { Mobile, LaptopUp } from "common/responsive"; import { EmptyBllock, BlockHeader, - CourseProgressBar, + ProgressList, } from "components/courses/common"; -import "./styles.scss"; - -// TODO: Implement Mobile Version; -// TODO: Refactor; - -const Lesson = ({ thumbnail, title, subtitle }) => { - return ( -
- - - - - -
-

{title}

-
{subtitle}
-
-
+import { LessonListItem } from "./list-item"; - -
- -

{title}

-
+import "./styles.scss"; -
-
{subtitle}
-
-
-
- ); -}; +export const LessonsBlock = ({ lessons = [] }) => { + const list = useMemo(() => { + if (!lessons || lessons.length === 0) return []; -const ProgressList = ({ lessons = [] }) => { - const data = useMemo(() => { return lessons.reduce((prev, current, index, array) => { const prevItem = prev[index - 1] || null; @@ -59,38 +30,23 @@ const ProgressList = ({ lessons = [] }) => { }, []); }, [lessons]); - return ( -
- {data.map((lesson, index) => ( -
- - - -
- ))} -
- ); -}; - -export const LessonsBlock = ({ lessons = [] }) => { return (
- {lessons.length === 0 && } - {lessons.length > 0 && } + {list.length === 0 && } + + {list.length > 0 && ( + ( + + )} + /> + )}
); }; diff --git a/src/components/courses/lessons/list-item/index.jsx b/src/components/courses/lessons/list-item/index.jsx new file mode 100644 index 000000000..bd8af3ae6 --- /dev/null +++ b/src/components/courses/lessons/list-item/index.jsx @@ -0,0 +1,32 @@ +import { Avatar } from "common/avatar"; +import { TabletUp, LaptopUp } from "common/responsive"; + +import "./styles.scss"; + +export const LessonListItem = ({ thumbnail, title, subtitle }) => { + return ( +
+ + + + + +
+

{title}

+
{subtitle}
+
+
+ + +
+ +

{title}

+
+ +
+
{subtitle}
+
+
+
+ ); +}; diff --git a/src/components/courses/lessons/list-item/styles.scss b/src/components/courses/lessons/list-item/styles.scss new file mode 100644 index 000000000..6f5360ae9 --- /dev/null +++ b/src/components/courses/lessons/list-item/styles.scss @@ -0,0 +1,37 @@ +@import "src/scss/mixins"; +@import "src/scss/colors"; +@import "src/scss/variables"; + +.lesson-item { + gap: $gap-big; + height: calc(120px + #{$gap-big}); + @include flexRow(center, flex-start); + + .avatar-container { + @include circle(120px); + } + + .info-block { + gap: $gap-small; + @include flexColumn(flex-start, flex-start); + + h5 { + @include setTextLines(3); + } + } + + .mobile-row-container { + gap: $gap-small; + @include flexRow(center, flex-start); + + .avatar-container { + @include circle(48px); + } + } + + @include breakpoint("tablet", "max") { + gap: $gap-small; + height: calc(128px + #{$gap-medium}); + @include flexColumn(flex-start, flex-start); + } +} diff --git a/src/components/courses/lessons/styles.scss b/src/components/courses/lessons/styles.scss index dfa77c841..9aeda923b 100644 --- a/src/components/courses/lessons/styles.scss +++ b/src/components/courses/lessons/styles.scss @@ -11,54 +11,3 @@ gap: $gap-big; } } - -.progress-list-container { - width: 100%; - margin-top: -$gap-big; - @include flexColumn(flex-start, flex-start); -} - -.lesson-list-item-container { - gap: 40px; - height: calc(120px + #{$gap-big}); - @include flexRow(center, center); - - .lesson-item { - gap: $gap-big; - @include flexRow(flex-start, flex-start); - - .avatar-container { - width: 120px; - height: 120px; - } - - .info-block { - gap: $gap-small; - @include flexColumn(flex-start, flex-start); - - h5 { - @include setTextLines(3); - } - } - - .mobile-row-container { - gap: $gap-small; - @include flexRow(center, flex-start); - - .avatar-container { - width: 48px; - height: 48px; - } - } - } - - @include breakpoint("mobile", "minMax") { - @include flexRow(flex-start, flex-start); - - .lesson-item { - gap: $gap-small; - height: calc(96px + #{$gap-medium}); - @include flexColumn(flex-start, center); - } - } -} diff --git a/src/hooks/useResponsive.js b/src/hooks/useResponsive.js index a922bde62..74653d2f4 100644 --- a/src/hooks/useResponsive.js +++ b/src/hooks/useResponsive.js @@ -18,18 +18,24 @@ export const isLaptop = () => maxWidth: DeviceMaxWidth.Laptop, }); -export const isTablet = () => +export const isTabletUp = () => useMediaQuery({ - minWidth: DeviceMaxWidth.Mobile + 1, + minWidth: 0, maxWidth: DeviceMaxWidth.Tablet, }); -export const isMobileUp = () => +export const isTablet = () => useMediaQuery({ - minWidth: 0, + minWidth: DeviceMaxWidth.Mobile + 1, maxWidth: DeviceMaxWidth.Tablet, }); +// export const isMobileUp = () => +// useMediaQuery({ +// minWidth: 0, +// maxWidth: DeviceMaxWidth.Tablet, +// }); + export const isMobile = () => useMediaQuery({ maxWidth: DeviceMaxWidth.Mobile, diff --git a/src/screens/courses/course/main-info/index.jsx b/src/screens/courses/course/main-info/index.jsx index 8d630b3c8..84c66ec87 100644 --- a/src/screens/courses/course/main-info/index.jsx +++ b/src/screens/courses/course/main-info/index.jsx @@ -1,6 +1,6 @@ import { Avatar } from "common/avatar"; import { StarsRating } from "common/stars-rating"; -import { MobileUp, LaptopUp } from "common/responsive"; +import { TabletUp, LaptopUp } from "common/responsive"; import "./styles.scss"; @@ -20,26 +20,26 @@ export const CourseMainInfo = ({ avatar, title, price, members, rating }) => {

{title}

- +

{title}

-
+

{coursePrice}

- +

{coursePrice}

-
+
{courseMembers}
- +
{courseMembers}
-
+
From 85a5b2cf0bff01e63c45a5ae848c7e90e6592df8 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 1 May 2022 22:22:45 +0300 Subject: [PATCH 24/34] add more button to course main info container --- src/assets/icons/archive.svg | 5 +++ src/assets/icons/message.svg | 6 +++ src/common/icon/index.jsx | 8 ++++ src/screens/courses/course/index.jsx | 22 +++++++--- .../courses/course/main-info/config.js | 19 +++++++++ .../courses/course/main-info/index.jsx | 42 +++++++++++++++---- .../courses/course/main-info/styles.scss | 9 ++-- 7 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 src/assets/icons/archive.svg create mode 100644 src/assets/icons/message.svg create mode 100644 src/screens/courses/course/main-info/config.js diff --git a/src/assets/icons/archive.svg b/src/assets/icons/archive.svg new file mode 100644 index 000000000..bacf9a5ef --- /dev/null +++ b/src/assets/icons/archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/message.svg b/src/assets/icons/message.svg new file mode 100644 index 000000000..972b11947 --- /dev/null +++ b/src/assets/icons/message.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/common/icon/index.jsx b/src/common/icon/index.jsx index e55f9fcc2..f90718b70 100644 --- a/src/common/icon/index.jsx +++ b/src/common/icon/index.jsx @@ -57,9 +57,14 @@ import { ReactComponent as StarOutlineIcon } from "assets/icons/star-outline.svg import { ReactComponent as StarIcon } from "assets/icons/star.svg"; import { ReactComponent as DownloadIcon } from "assets/icons/download.svg"; import { ReactComponent as CheckmarkRoundIcon } from "assets/icons/checkmark-round.svg"; +import { ReactComponent as MessageIcon } from "assets/icons/message.svg"; +import { ReactComponent as ArchiveIcon } from "assets/icons/archive.svg"; const getIcon = (iconName) => { switch (iconName) { + case "archive": + return ; + case "book": return ; @@ -163,6 +168,9 @@ const getIcon = (iconName) => { case "link": return ; + case "message": + return ; + case "more": return ; diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 574eed630..66d21f890 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; @@ -27,6 +28,12 @@ export const CoursePage = () => { const course = useSelector((state) => selectCurrentCourse(state, id)); // const currentUser = useSelector(selectCurrentUser); + // TODO: Check course.isPaid === true; + const isPaidCourse = useMemo(() => id === "0", [id]); + + // TODO: Check course.author === currentUser.id; + const isMyCourse = useMemo(() => id === "0", [id]); + const handleBuyCourse = () => {}; return ( @@ -39,17 +46,20 @@ export const CoursePage = () => { - + {!isPaidCourse && !isMyCourse && ( + + )} diff --git a/src/screens/courses/course/main-info/config.js b/src/screens/courses/course/main-info/config.js new file mode 100644 index 000000000..516cab543 --- /dev/null +++ b/src/screens/courses/course/main-info/config.js @@ -0,0 +1,19 @@ +export const MoreOption = { + Review: "Review", + Archive: "Archive", +}; + +export const moreOptions = [ + { + value: MoreOption.Review, + label: "Add Review", + icon: "message", + variant: "white", + }, + { + value: MoreOption.Archive, + label: "Add to Archive", + icon: "archive", + variant: "white", + }, +]; diff --git a/src/screens/courses/course/main-info/index.jsx b/src/screens/courses/course/main-info/index.jsx index 84c66ec87..1cc05bad1 100644 --- a/src/screens/courses/course/main-info/index.jsx +++ b/src/screens/courses/course/main-info/index.jsx @@ -1,28 +1,56 @@ import { Avatar } from "common/avatar"; import { StarsRating } from "common/stars-rating"; import { TabletUp, LaptopUp } from "common/responsive"; +import { ModalOptionsButton } from "common/buttons/modal-options-button"; + +import { MoreOption, moreOptions } from "./config"; import "./styles.scss"; -export const CourseMainInfo = ({ avatar, title, price, members, rating }) => { +export const CourseMainInfo = ({ + title, + price, + avatar, + rating, + isPaid, + members, + onAddReview, + onAddToArchive, +}) => { const coursePrice = price ? `$${parseFloat(parseFloat(price) / 100).toFixed(2)}` : "$00.00"; const courseMembers = `${members || 0} people tried`; + const handleMoreOptionClick = (option) => { + if (option === MoreOption.Review && onAddReview) onAddReview(); + if (option === MoreOption.Archive && onAddToArchive) onAddToArchive(); + }; + return (
- -

{title}

-
+
+ +

{title}

+
- -

{title}

-
+ +

{title}

+
+ + {isPaid && ( + + )} +

{coursePrice}

diff --git a/src/screens/courses/course/main-info/styles.scss b/src/screens/courses/course/main-info/styles.scss index 987e6c72b..407ebbaa6 100644 --- a/src/screens/courses/course/main-info/styles.scss +++ b/src/screens/courses/course/main-info/styles.scss @@ -6,9 +6,7 @@ @include flexRow(flex-start, flex-start); .avatar-container { - width: 152px; - height: 152px; - border-radius: 152px; + @include circle(152px); } .info-column-container { @@ -16,6 +14,11 @@ gap: 8px; @include flexColumn(flex-start, space-between); + .title-container { + width: 100%; + @include flexRow(center, space-between); + } + .bottom-container { gap: 8px; width: 100%; From e405787a7f5b25340078aed3d69cd671e0492982 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 1 May 2022 23:04:05 +0300 Subject: [PATCH 25/34] implement course and lessons more options --- .../buttons/modal-options-button/styles.scss | 5 +++ .../page-header/mobile-menu/styles.scss | 2 +- src/components/courses/lessons/index.jsx | 3 +- .../courses/lessons/list-item/index.jsx | 44 +++++++++++++++++-- .../courses/lessons/list-item/styles.scss | 11 +++++ src/constants/enums.js | 28 ++++++++++++ src/screens/courses/course/index.jsx | 5 ++- .../courses/course/main-info/config.js | 19 -------- .../courses/course/main-info/index.jsx | 8 +++- src/utils/createMoreOption.js | 12 +++++ 10 files changed, 111 insertions(+), 26 deletions(-) delete mode 100644 src/screens/courses/course/main-info/config.js create mode 100644 src/utils/createMoreOption.js diff --git a/src/common/buttons/modal-options-button/styles.scss b/src/common/buttons/modal-options-button/styles.scss index 9702e8516..9be0595e7 100644 --- a/src/common/buttons/modal-options-button/styles.scss +++ b/src/common/buttons/modal-options-button/styles.scss @@ -18,6 +18,7 @@ top: 32px; right: 0; + z-index: 1; position: absolute; .option-container { @@ -52,6 +53,10 @@ @include setSvgColor(#da3443); background-color: transparent; + h4 { + color: #da3443; + } + &:hover { background-color: #1d2120; } diff --git a/src/common/page-header/mobile-menu/styles.scss b/src/common/page-header/mobile-menu/styles.scss index b55ec8308..5cb187888 100644 --- a/src/common/page-header/mobile-menu/styles.scss +++ b/src/common/page-header/mobile-menu/styles.scss @@ -11,7 +11,7 @@ overflow-x: hidden; overflow-y: auto; - z-index: 1; + z-index: 15; position: fixed; &-visible { diff --git a/src/components/courses/lessons/index.jsx b/src/components/courses/lessons/index.jsx index 8629d49b1..912f6f7b7 100644 --- a/src/components/courses/lessons/index.jsx +++ b/src/components/courses/lessons/index.jsx @@ -10,7 +10,7 @@ import { LessonListItem } from "./list-item"; import "./styles.scss"; -export const LessonsBlock = ({ lessons = [] }) => { +export const LessonsBlock = ({ lessons = [], isMyCourse = false }) => { const list = useMemo(() => { if (!lessons || lessons.length === 0) return []; @@ -40,6 +40,7 @@ export const LessonsBlock = ({ lessons = [] }) => { list={list} render={(item, index) => ( { +const moreOptions = [ + createMoreOption(MoreOption.Edit), + createMoreOption(MoreOption.Delete), +]; + +export const LessonListItem = ({ + thumbnail, + title, + subtitle, + isMyCourse = true, +}) => { + const handleOptionSelect = (option) => {}; + return (
@@ -12,7 +28,18 @@ export const LessonListItem = ({ thumbnail, title, subtitle }) => {
-

{title}

+
+

{title}

+ {isMyCourse && ( + + )} +
+
{subtitle}
@@ -20,7 +47,18 @@ export const LessonListItem = ({ thumbnail, title, subtitle }) => {
-

{title}

+ +
+

{title}

+ {isMyCourse && ( + + )} +
diff --git a/src/components/courses/lessons/list-item/styles.scss b/src/components/courses/lessons/list-item/styles.scss index 6f5360ae9..57154e26a 100644 --- a/src/components/courses/lessons/list-item/styles.scss +++ b/src/components/courses/lessons/list-item/styles.scss @@ -15,15 +15,26 @@ gap: $gap-small; @include flexColumn(flex-start, flex-start); + .title-container { + width: 100%; + @include flexRow(center, space-between); + } + h5 { @include setTextLines(3); } } .mobile-row-container { + width: 100%; gap: $gap-small; @include flexRow(center, flex-start); + .title-container { + width: 100%; + @include flexRow(center, space-between); + } + .avatar-container { @include circle(48px); } diff --git a/src/constants/enums.js b/src/constants/enums.js index 1f2d84304..91a3ea855 100644 --- a/src/constants/enums.js +++ b/src/constants/enums.js @@ -63,3 +63,31 @@ export const ContentType = { Video: "Video", Material: "Material", }; + +export const MoreOption = { + Edit: "Edit", + Review: "Review", + Delete: "Delete", + Archive: "Archive", +}; + +export const MoreOptionLabel = { + [MoreOption.Edit]: "Edit", + [MoreOption.Review]: "Add Review", + [MoreOption.Archive]: "Add to Archive", + [MoreOption.Delete]: "Delete", +}; + +export const MoreOptionIcon = { + [MoreOption.Edit]: "edit", + [MoreOption.Review]: "message", + [MoreOption.Archive]: "archive", + [MoreOption.Delete]: "trash", +}; + +export const MoreOptionVariant = { + [MoreOption.Edit]: "white", + [MoreOption.Review]: "white", + [MoreOption.Archive]: "white", + [MoreOption.Delete]: "red", +}; diff --git a/src/screens/courses/course/index.jsx b/src/screens/courses/course/index.jsx index 66d21f890..e405f3858 100644 --- a/src/screens/courses/course/index.jsx +++ b/src/screens/courses/course/index.jsx @@ -73,7 +73,10 @@ export const CoursePage = () => { /> - +
({ + value: option, + ...(MoreOptionIcon[option] && { icon: MoreOptionIcon[option] }), + ...(MoreOptionLabel[option] && { label: MoreOptionLabel[option] }), + ...(MoreOptionVariant[option] && { variant: MoreOptionVariant[option] }), +}); From eff4565fddfa312044cc3cea58ba83e7ecfe5a95 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 3 May 2022 14:38:10 +0300 Subject: [PATCH 26/34] minor changes --- src/components/courses/lessons/list-item/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/courses/lessons/list-item/index.jsx b/src/components/courses/lessons/list-item/index.jsx index ad276d649..701997a7d 100644 --- a/src/components/courses/lessons/list-item/index.jsx +++ b/src/components/courses/lessons/list-item/index.jsx @@ -18,7 +18,7 @@ export const LessonListItem = ({ subtitle, isMyCourse = true, }) => { - const handleOptionSelect = (option) => {}; + const handleOptionSelect = () => {}; return (
From d7fb3777b16d09678848b64203960a5b2845b11f Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 May 2022 23:38:33 +0300 Subject: [PATCH 27/34] implement members page --- src/assets/icons/grid-four.svg | 6 + src/assets/icons/grid-two.svg | 4 + src/common/grids/flex/index.jsx | 18 ++ src/common/grids/flex/styles.scss | 4 + src/common/grids/index.js | 1 + src/common/icon/index.jsx | 11 +- src/components/courses/blocks/index.js | 1 - .../courses/blocks/members/index.jsx | 52 ----- .../courses/blocks/members/styles.scss | 73 ------- .../courses/course-list-item/index.jsx | 3 +- src/components/courses/index.js | 5 + .../courses/members-list/grid-list/index.jsx | 40 ++++ .../members-list/grid-list/styles.scss | 17 ++ src/components/courses/members-list/index.jsx | 41 ++++ .../members-list/member-item/index.jsx | 36 ++++ .../members-list/member-item/styles.scss | 75 ++++++++ .../courses/members-list/preview/index.jsx | 38 ++++ .../courses/members-list/preview/styles.scss | 20 ++ .../courses/members-list/styles.scss | 0 src/constants/routes.js | 19 ++ src/hooks/courses/useCoursesList.js | 2 +- src/layout/dashboard/index.jsx | 6 + src/layout/dashboard/styles.scss | 15 +- src/routes/index.jsx | 60 +++++- src/screens/courses/course/index.jsx | 28 ++- .../courses/course/main-info/index.jsx | 2 +- src/screens/courses/index.js | 1 + src/screens/courses/members/index.jsx | 76 ++++++++ src/screens/courses/members/styles.scss | 8 + src/store/courses/slice.js | 4 +- src/store/courses/thunks.js | 2 +- src/utils/mocked.js | 180 ------------------ src/utils/mocked/courses.js | 13 +- 33 files changed, 521 insertions(+), 340 deletions(-) create mode 100644 src/assets/icons/grid-four.svg create mode 100644 src/assets/icons/grid-two.svg create mode 100644 src/common/grids/flex/index.jsx create mode 100644 src/common/grids/flex/styles.scss delete mode 100644 src/components/courses/blocks/index.js delete mode 100644 src/components/courses/blocks/members/index.jsx delete mode 100644 src/components/courses/blocks/members/styles.scss create mode 100644 src/components/courses/members-list/grid-list/index.jsx create mode 100644 src/components/courses/members-list/grid-list/styles.scss create mode 100644 src/components/courses/members-list/index.jsx create mode 100644 src/components/courses/members-list/member-item/index.jsx create mode 100644 src/components/courses/members-list/member-item/styles.scss create mode 100644 src/components/courses/members-list/preview/index.jsx create mode 100644 src/components/courses/members-list/preview/styles.scss create mode 100644 src/components/courses/members-list/styles.scss create mode 100644 src/constants/routes.js create mode 100644 src/screens/courses/members/index.jsx create mode 100644 src/screens/courses/members/styles.scss delete mode 100644 src/utils/mocked.js diff --git a/src/assets/icons/grid-four.svg b/src/assets/icons/grid-four.svg new file mode 100644 index 000000000..81a2b0e7f --- /dev/null +++ b/src/assets/icons/grid-four.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/grid-two.svg b/src/assets/icons/grid-two.svg new file mode 100644 index 000000000..e89055cd6 --- /dev/null +++ b/src/assets/icons/grid-two.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/common/grids/flex/index.jsx b/src/common/grids/flex/index.jsx new file mode 100644 index 000000000..508061df3 --- /dev/null +++ b/src/common/grids/flex/index.jsx @@ -0,0 +1,18 @@ +import "./styles.scss"; + +export const Flex = ({ gap, direction, align, justify, children, styles }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/common/grids/flex/styles.scss b/src/common/grids/flex/styles.scss new file mode 100644 index 000000000..5d1f27026 --- /dev/null +++ b/src/common/grids/flex/styles.scss @@ -0,0 +1,4 @@ +.flex-container { + width: 100%; + display: flex; +} diff --git a/src/common/grids/index.js b/src/common/grids/index.js index b2e1ea27e..4baddbecb 100644 --- a/src/common/grids/index.js +++ b/src/common/grids/index.js @@ -1 +1,2 @@ +export { Flex } from "./flex"; export { TwoColumnsGrid } from "./two-columns-grid"; diff --git a/src/common/icon/index.jsx b/src/common/icon/index.jsx index f90718b70..897e30ef8 100644 --- a/src/common/icon/index.jsx +++ b/src/common/icon/index.jsx @@ -59,6 +59,8 @@ import { ReactComponent as DownloadIcon } from "assets/icons/download.svg"; import { ReactComponent as CheckmarkRoundIcon } from "assets/icons/checkmark-round.svg"; import { ReactComponent as MessageIcon } from "assets/icons/message.svg"; import { ReactComponent as ArchiveIcon } from "assets/icons/archive.svg"; +import { ReactComponent as GridFourIcon } from "assets/icons/grid-four.svg"; +import { ReactComponent as GridTwoIcon } from "assets/icons/grid-two.svg"; const getIcon = (iconName) => { switch (iconName) { @@ -89,9 +91,8 @@ const getIcon = (iconName) => { case "checkmark": return ; - case "checkmark-round": { + case "checkmark-round": return ; - } case "chevron-left": return ; @@ -132,6 +133,12 @@ const getIcon = (iconName) => { case "grid": return ; + case "grid-four": + return ; + + case "grid-two": + return ; + case "facebook": return ; diff --git a/src/components/courses/blocks/index.js b/src/components/courses/blocks/index.js deleted file mode 100644 index b966c6810..000000000 --- a/src/components/courses/blocks/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from "./members"; diff --git a/src/components/courses/blocks/members/index.jsx b/src/components/courses/blocks/members/index.jsx deleted file mode 100644 index 77ce20025..000000000 --- a/src/components/courses/blocks/members/index.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback } from "react"; -import cx from "classnames"; - -import { Icon } from "common/icon"; -import { Avatar } from "common/avatar"; - -import "./styles.scss"; - -const ViewAllComponent = () => ( -
-

View All

- -
-); - -export const MembersBlock = ({ list = [], onSelectMember, onViewAll }) => { - if (!list || list.length === 0) return null; - - const members = list.slice(0, 6); - - const isLastItem = useCallback( - (index) => index === members.length - 1, - [members] - ); - - const getClassName = useCallback( - (index) => { - const classname = "member-container"; - return cx(classname, { [`${classname}-last-member`]: isLastItem(index) }); - }, - [members] - ); - - return ( -
-
- {members.map((item, index) => ( -
(isLastItem ? onViewAll() : onSelectMember(item.id))} - > - -

{item.name}

- - {isLastItem(index) && } -
- ))} -
-
- ); -}; diff --git a/src/components/courses/blocks/members/styles.scss b/src/components/courses/blocks/members/styles.scss deleted file mode 100644 index 9a33e2a0f..000000000 --- a/src/components/courses/blocks/members/styles.scss +++ /dev/null @@ -1,73 +0,0 @@ -@import "src/scss/mixins"; -@import "src/scss/variables"; - -.members-container { - width: 100%; - overflow-y: scroll; - - .scroll-container { - width: fit-content; - height: fit-content; - gap: $gap-big; - @include flexRow(flex-start, flex-start); - - .member-container { - cursor: pointer; - position: relative; - gap: $gap-smallest; - @include flexColumn(center, flex-start); - - .avatar-container { - @include circle(152px); - } - - h4 { - text-align: center; - @include breakEachWord(); - } - - &-last-member { - .avatar-container { - opacity: 0.4; - } - - h4 { - opacity: 0.4; - } - - .view-all-container { - width: 100%; - @include flexRow(center, center); - @include position(absolute, 75px); - - h4 { - width: auto; - opacity: 1; - } - } - } - - &:hover { - opacity: 0.8; - } - } - } - - @include breakpoint("tablet", "max") { - .scroll-container { - gap: $gap-medium; - - .member-container { - .avatar-container { - @include circle(120px); - } - - &-last-member { - .view-all-container { - @include position(absolute, 55px); - } - } - } - } - } -} diff --git a/src/components/courses/course-list-item/index.jsx b/src/components/courses/course-list-item/index.jsx index f09e89618..2d8cd3827 100644 --- a/src/components/courses/course-list-item/index.jsx +++ b/src/components/courses/course-list-item/index.jsx @@ -38,7 +38,8 @@ export const CoursesListItem = forwardRef( const membersComponent = () => { if (variant !== CourseListType.My) return null; - const courseMembers = members ? `${members} students` : "0 students"; + const courseMembers = + members?.length > 0 ? `${members.length} students` : "0 students"; return

{courseMembers}

; }; diff --git a/src/components/courses/index.js b/src/components/courses/index.js index ddc2d84b5..beff4bdf1 100644 --- a/src/components/courses/index.js +++ b/src/components/courses/index.js @@ -2,3 +2,8 @@ export * from "./course-list-item"; export { ReviewsBlock } from "./reviews/block"; export { MeterialsBlock } from "./materials/block"; export { LessonsBlock } from "./lessons"; +export { + MembersList, + MembersListPreview, + MembersListItemGrid, +} from "./members-list"; diff --git a/src/components/courses/members-list/grid-list/index.jsx b/src/components/courses/members-list/grid-list/index.jsx new file mode 100644 index 000000000..5bcdb10e4 --- /dev/null +++ b/src/components/courses/members-list/grid-list/index.jsx @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import cx from "classnames"; + +import { MemberListItem, ItemVariant } from "../member-item"; + +import "./styles.scss"; + +export const MembersListItemGrid = { + row: "row", + column: "column", +}; + +const MemberItemVariant = { + [MembersListItemGrid.row]: ItemVariant.Grid, + [MembersListItemGrid.column]: ItemVariant.List, +}; + +export const MembersListGrid = ({ list = [], grid, onSelectMember }) => { + const className = useMemo( + () => + cx("members-list-container", { + [`members-list-container-${grid}`]: true, + }), + [grid] + ); + + return ( +
+ {list.map((item, index) => ( + onSelectMember(item.id)} + /> + ))} +
+ ); +}; diff --git a/src/components/courses/members-list/grid-list/styles.scss b/src/components/courses/members-list/grid-list/styles.scss new file mode 100644 index 000000000..a6aff5331 --- /dev/null +++ b/src/components/courses/members-list/grid-list/styles.scss @@ -0,0 +1,17 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.members-list-container { + width: 100%; + + &-row { + gap: $gap-big; + flex-wrap: wrap; + @include flexRow(flex-start, flex-start); + } + + &-column { + gap: $gap-medium; + @include flexColumn(flex-start, flex-start); + } +} diff --git a/src/components/courses/members-list/index.jsx b/src/components/courses/members-list/index.jsx new file mode 100644 index 000000000..dd80090bf --- /dev/null +++ b/src/components/courses/members-list/index.jsx @@ -0,0 +1,41 @@ +import { MembersListPreview } from "./preview"; +import { MembersListGrid, MembersListItemGrid } from "./grid-list"; + +const Variant = { List: "list", Preview: "preview" }; + +const MembersList = ({ + list = [], + onViewAll, + maxLength, + onSelectMember, + variant = Variant.Preview, + grid = MembersListItemGrid.row, +}) => { + if (!list || list.length === 0) return null; + + if (variant === Variant.Preview) { + return ( + + ); + } + + if (variant === Variant.List) { + return ( + + ); + } + + return null; +}; + +export { MembersListItemGrid, MembersList, MembersListPreview }; diff --git a/src/components/courses/members-list/member-item/index.jsx b/src/components/courses/members-list/member-item/index.jsx new file mode 100644 index 000000000..2ff6f9c3f --- /dev/null +++ b/src/components/courses/members-list/member-item/index.jsx @@ -0,0 +1,36 @@ +import cx from "classnames"; + +import { Icon } from "common/icon"; +import { Avatar } from "common/avatar"; + +import "./styles.scss"; + +export const ItemVariant = { Grid: "grid", List: "list" }; + +const ViewAllComponent = () => ( +
+

View All

+ +
+); + +export const MemberListItem = ({ + name, + avatar, + onClick, + isLastPreviewItem, + variant = ItemVariant.Grid, +}) => { + const className = cx("member-container", { + [`member-container-${variant}`]: true, + "member-container-last-preview": isLastPreviewItem, + }); + + return ( +
+ +

{name}

+ {isLastPreviewItem && } +
+ ); +}; diff --git a/src/components/courses/members-list/member-item/styles.scss b/src/components/courses/members-list/member-item/styles.scss new file mode 100644 index 000000000..24115930f --- /dev/null +++ b/src/components/courses/members-list/member-item/styles.scss @@ -0,0 +1,75 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.member-container { + cursor: pointer; + + &-grid { + gap: $gap-smallest; + @include flexColumn(center, flex-start); + + .avatar-container { + @include circle(152px); + } + + h4 { + text-align: center; + @include breakEachWord(); + } + } + + &-list { + width: 100%; + height: 72px; + gap: $gap-small; + @include flexRow(center, flex-start); + + .avatar-container { + @include circle(72px); + } + } + + &-last-preview { + position: relative; + .avatar-container { + opacity: 0.4; + } + + h4 { + opacity: 0.4; + } + } + + &:hover { + opacity: 0.8; + } + + @include breakpoint("tablet", "max") { + &-grid { + .avatar-container { + @include circle(120px); + } + } + + &-list { + .avatar-container { + @include circle(48px); + } + } + } +} + +.view-all-absolute-container { + width: 100%; + @include flexRow(center, center); + @include position(absolute, 75px); + + h4 { + width: auto; + opacity: 1; + } + + @include breakpoint("tablet", "max") { + @include position(absolute, 55px); + } +} diff --git a/src/components/courses/members-list/preview/index.jsx b/src/components/courses/members-list/preview/index.jsx new file mode 100644 index 000000000..425bad070 --- /dev/null +++ b/src/components/courses/members-list/preview/index.jsx @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { MemberListItem } from "../member-item"; +import "./styles.scss"; + +export const MembersListPreview = ({ + list = [], + onViewAll, + maxLength = 6, + onSelectMember, +}) => { + const members = list ? list.slice(0, maxLength) : []; + + const isLastItem = useCallback( + (index) => index === members.length - 1, + [members] + ); + + return ( +
+
+ {members.map((item, index) => ( + onViewAll() + : () => onSelectMember(item.id) + } + /> + ))} +
+
+ ); +}; diff --git a/src/components/courses/members-list/preview/styles.scss b/src/components/courses/members-list/preview/styles.scss new file mode 100644 index 000000000..ecf82906e --- /dev/null +++ b/src/components/courses/members-list/preview/styles.scss @@ -0,0 +1,20 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.members-preview-container { + width: 100%; + overflow-y: scroll; + + .scroll-container { + width: fit-content; + height: fit-content; + gap: $gap-big; + @include flexRow(flex-start, flex-start); + } + + @include breakpoint("tablet", "max") { + .scroll-container { + gap: $gap-medium; + } + } +} diff --git a/src/components/courses/members-list/styles.scss b/src/components/courses/members-list/styles.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/constants/routes.js b/src/constants/routes.js new file mode 100644 index 000000000..676e61b51 --- /dev/null +++ b/src/constants/routes.js @@ -0,0 +1,19 @@ +const news = "/news"; +const courses = "/courses"; + +const News = { + Home: news, + Article: `${news}/:id`, + Create: `${news}/create`, + Edit: `${news}/edit/:id`, + Preview: `${news}/preview`, +}; + +const Courses = { + Home: courses, + Course: `${courses}/:id`, + Create: `${courses}/create`, + Members: `${courses}/:id/members`, +}; + +export const Routes = { News, Courses }; diff --git a/src/hooks/courses/useCoursesList.js b/src/hooks/courses/useCoursesList.js index 2b6948e9c..58d5a06d6 100644 --- a/src/hooks/courses/useCoursesList.js +++ b/src/hooks/courses/useCoursesList.js @@ -4,9 +4,9 @@ import { useAlert } from "react-alert"; import { api } from "api"; import { SortOption } from "constants/enums"; -import { mockedCourses } from "utils/mocked"; import { getErrorMessage } from "utils/error"; import { useSearchBar } from "providers/search-bar"; +import { mockedCourses } from "utils/mocked/courses"; const sortCoursesBy = ({ list = [], sortType }) => { return list.sort((a, b) => { diff --git a/src/layout/dashboard/index.jsx b/src/layout/dashboard/index.jsx index dc0bbb496..df6bd25b7 100644 --- a/src/layout/dashboard/index.jsx +++ b/src/layout/dashboard/index.jsx @@ -5,10 +5,12 @@ import { DeviceType } from "constants/enums"; import { PageHeader } from "common/page-header"; import "./styles.scss"; +import { ActionButton } from "common/buttons/action-button"; export const DashboardLayout = ({ title, children, + addButtonProps, withBackButton = false, }) => { const { device } = useDeviceType(); @@ -29,6 +31,10 @@ export const DashboardLayout = ({ {showPageTitle && (

{title}

+ + {addButtonProps && ( + + )}
)}
{children}
diff --git a/src/layout/dashboard/styles.scss b/src/layout/dashboard/styles.scss index 4f29bcbde..16bae284b 100644 --- a/src/layout/dashboard/styles.scss +++ b/src/layout/dashboard/styles.scss @@ -29,11 +29,19 @@ justify-content: flex-start; .page-title-header { + gap: 16px; width: 100%; max-width: $max-content-width; margin-top: 16px; margin-bottom: 8px; padding: 0 40px; + + flex-wrap: wrap; + @include flexRow(center, space-between); + + button { + width: 248px; + } } } @@ -46,8 +54,11 @@ @include breakpoint("tablet", "max") { .dashboard-page-container { .page-title-header { - padding-left: 16px; - padding-right: 16px; + padding: 0 16px; + + button { + width: 100%; + } } } .dashboard-page { diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 18218db5c..e56470364 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -5,6 +5,7 @@ import { HTML5Backend } from "react-dnd-html5-backend"; import { Switch, Redirect } from "react-router-dom"; import { PrivateRoute } from "components/privateRoute"; +import { Routes as PathRoutes } from "constants/routes"; // News import { @@ -45,6 +46,7 @@ import EditCollection from "../screens/courses/editCollection/EditCollection"; import CourseCollection from "../screens/courses/courseCollection/CourseCollection"; import { CoursePage, + MembersPage, CoursesListPage, CreateCoursePage, } from "../screens/courses"; @@ -85,11 +87,55 @@ import { NotFoundPage } from "../screens/not-found"; export const Routes = () => { return ( - - - - - + {/* News */} + + + + + + + {/* Courses */} + + + + + {/* */} @@ -133,10 +179,6 @@ export const Routes = () => { /> - - - - {/* */} { const { id } = useParams(); + const history = useHistory(); const course = useSelector((state) => selectCurrentCourse(state, id)); // const currentUser = useSelector(selectCurrentUser); @@ -66,10 +73,15 @@ export const CoursePage = () => { - {}} - onViewAll={() => {}} + + history.push(Routes.Courses.Members.replace(":id", id)) + } + onSelectMember={() => + history.push(Routes.Courses.Members.replace(":id", id)) + } /> diff --git a/src/screens/courses/course/main-info/index.jsx b/src/screens/courses/course/main-info/index.jsx index 8acf4db26..276b7d5f9 100644 --- a/src/screens/courses/course/main-info/index.jsx +++ b/src/screens/courses/course/main-info/index.jsx @@ -27,7 +27,7 @@ export const CourseMainInfo = ({ ? `$${parseFloat(parseFloat(price) / 100).toFixed(2)}` : "$00.00"; - const courseMembers = `${members || 0} people tried`; + const courseMembers = `${members.length || 0} people tried`; const handleMoreOptionClick = (option) => { if (option === MoreOption.Review && onAddReview) onAddReview(); diff --git a/src/screens/courses/index.js b/src/screens/courses/index.js index b494c2898..5de906d05 100644 --- a/src/screens/courses/index.js +++ b/src/screens/courses/index.js @@ -1,3 +1,4 @@ export { CoursePage } from "./course"; +export { MembersPage } from "./members"; export { CoursesListPage } from "./courses-list"; export { CreateCoursePage } from "./create-course"; diff --git a/src/screens/courses/members/index.jsx b/src/screens/courses/members/index.jsx new file mode 100644 index 000000000..71a9e3058 --- /dev/null +++ b/src/screens/courses/members/index.jsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router-dom"; + +import { Flex } from "common/grids"; +import { DashboardLayout } from "layout/dashboard"; +import { TabletUp, LaptopUp } from "common/responsive"; +import { IconButton } from "common/buttons/icon-button"; +import { MembersList, MembersListItemGrid } from "components/courses"; + +import { selectCurrentCourse } from "store/courses"; + +import "./styles.scss"; + +export const MembersPage = () => { + const { id: courseId } = useParams(); + const course = useSelector((state) => selectCurrentCourse(state, courseId)); + + const [grid, setGrid] = useState(MembersListItemGrid.row); + + const handleInviteMemberClick = () => {}; + + const handleMemberClick = () => {}; + + return ( + +
+ + + setGrid(MembersListItemGrid.row)} + variant={ + grid === MembersListItemGrid.row ? "transparent" : "white" + } + /> + setGrid(MembersListItemGrid.column)} + variant={ + grid === MembersListItemGrid.column ? "transparent" : "white" + } + /> + + + + + + + + + + +
+
+ ); +}; diff --git a/src/screens/courses/members/styles.scss b/src/screens/courses/members/styles.scss new file mode 100644 index 000000000..eaabb4d6c --- /dev/null +++ b/src/screens/courses/members/styles.scss @@ -0,0 +1,8 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.members-page-container { + width: 100%; + gap: $gap-big; + @include flexColumn(flex-start, flex-start); +} diff --git a/src/store/courses/slice.js b/src/store/courses/slice.js index 1a259a3c4..9610022a1 100644 --- a/src/store/courses/slice.js +++ b/src/store/courses/slice.js @@ -1,9 +1,9 @@ import { createSlice } from "@reduxjs/toolkit"; -import { mockedCourses } from "utils/mocked"; +import { mockedCourses } from "utils/mocked/courses"; const initialState = { - list: mockedCourses, + list: [...mockedCourses], selectedArticle: null, }; diff --git a/src/store/courses/thunks.js b/src/store/courses/thunks.js index f6dfe7b45..45f94a32a 100644 --- a/src/store/courses/thunks.js +++ b/src/store/courses/thunks.js @@ -1,4 +1,4 @@ -import { mockedCourses } from "utils/mocked"; +import { mockedCourses } from "utils/mocked/courses"; import { createCourse, setCourses } from "./slice"; diff --git a/src/utils/mocked.js b/src/utils/mocked.js deleted file mode 100644 index a6a9306bf..000000000 --- a/src/utils/mocked.js +++ /dev/null @@ -1,180 +0,0 @@ -import { ContentBuilderAction } from "constants/enums"; - -export const mockedCourses = [ - { - id: "0", - title: "A Fueling the Content", - price: 2333, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 4, - progress: 12, - members: 20, - createdAt: new Date(2021, 11, 17), - - content: [ - { - type: ContentBuilderAction.Text, - title: "Senectus sed facilisis egestas adipiscing mauris.", - text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", - }, - { - type: ContentBuilderAction.Image, - url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - description: "Combine Harvester swather", - }, - { - type: ContentBuilderAction.Text, - title: "Senectus sed facilisis egestas adipiscing mauris.", - text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", - }, - { - type: ContentBuilderAction.Text, - // title: "Senectus sed facilisis egestas adipiscing mauris.", - text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", - }, - { - type: ContentBuilderAction.Text, - // title: "Senectus sed facilisis egestas adipiscing mauris.", - text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", - }, - { - type: ContentBuilderAction.Image, - url: "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - description: "Combine Harvester swather", - }, - { - type: ContentBuilderAction.Text, - title: "Senectus sed facilisis egestas adipiscing mauris.", - text: "Malesuada sit ac at at eu non, magna. Ut fringilla dui euismod congue bibendum leo venenatis convallis. Platea bibendum mauris etiam in ut nunc praesent. Neque diam proin diam a, id malesuada. Ut faucibus est turpis ullamcorper. Adipiscing adipiscing pretium faucibus vulputate fringilla. Tortor risus aliquam sit orci et adipiscing. Euismod nunc interdum donec purus etiam diam imperdiet facilisis. Ligula egestas sit commodo purus, amet ut rhoncus, et.", - }, - ], - - membersList: [ - { - id: "0", - name: "Jenny Wilson", - avatar: - "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", - }, - { - id: "1", - name: "Russel Diane", - }, - { - id: "2", - name: "Alambet Brojik", - avatar: - "https://image.shutterstock.com/image-photo/young-girl-makes-favor-condescendingly-260nw-1287061849.jpg", - }, - { - id: "3", - name: "Resie Cooper", - avatar: - "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", - }, - { - id: "4", - name: "Justin Biber", - }, - { - id: "5", - name: "Amal Kekov", - avatar: - "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", - }, - { - id: "6", - name: "Elizhabet Perkins", - avatar: - "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", - }, - { - id: "7", - name: "Ronald Richardson", - avatar: - "https://st4.depositphotos.com/13768208/21182/i/600/depositphotos_211827780-stock-photo-crazy-girl-showing-her-palms.jpg", - }, - { - id: "8", - name: "Abram Ibragimov", - }, - { - id: "9", - name: "Sheldon Cooper", - avatar: - "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlcnxlbnwwfHwwfHw%3D&w=1000&q=80", - }, - ], - }, - { - id: "1", - title: "B Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 5, - progress: 70, - members: 10, - createdAt: new Date(2020, 5, 17), - }, - { - id: "2", - title: "C Fueling the ethanol industry", - price: 122, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 5, - progress: 40, - members: 50, - createdAt: new Date(2020, 11, 5), - }, - { - id: "3", - title: "Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 1, - progress: 40, - members: 100, - createdAt: new Date(2022, 4, 17), - }, - { - id: "4", - title: "Fueling the ethanol industry", - price: 2599, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: null, - category: "Farm", - rating: 2, - progress: 45, - members: 0, - createdAt: new Date(2022, 11, 17), - }, - { - id: "5", - title: "Fueling the ethanol industry", - price: 3400, - description: - "Sit ultrices et, arcu posuere dolor sollicitudin lorem sed. Nisi non felis, in sem quisque neque scelerisque.", - avatar: - "https://natureconservancy-h.assetsadobe.com/is/image/content/dam/tnc/nature/en/photos/WOPA160517_D056-resized.jpg?crop=864,0,1728,2304&wid=600&hei=800&scl=2.88", - category: "Farm", - rating: 3, - progress: 90, - members: 200, - createdAt: new Date(2022, 11, 17), - }, -]; diff --git a/src/utils/mocked/courses.js b/src/utils/mocked/courses.js index a6a9306bf..3102adbba 100644 --- a/src/utils/mocked/courses.js +++ b/src/utils/mocked/courses.js @@ -12,7 +12,6 @@ export const mockedCourses = [ category: "Farm", rating: 4, progress: 12, - members: 20, createdAt: new Date(2021, 11, 17), content: [ @@ -53,7 +52,7 @@ export const mockedCourses = [ }, ], - membersList: [ + members: [ { id: "0", name: "Jenny Wilson", @@ -120,7 +119,7 @@ export const mockedCourses = [ category: "Farm", rating: 5, progress: 70, - members: 10, + members: [], createdAt: new Date(2020, 5, 17), }, { @@ -133,7 +132,7 @@ export const mockedCourses = [ category: "Farm", rating: 5, progress: 40, - members: 50, + members: [], createdAt: new Date(2020, 11, 5), }, { @@ -147,7 +146,7 @@ export const mockedCourses = [ category: "Farm", rating: 1, progress: 40, - members: 100, + members: [], createdAt: new Date(2022, 4, 17), }, { @@ -160,7 +159,7 @@ export const mockedCourses = [ category: "Farm", rating: 2, progress: 45, - members: 0, + members: [], createdAt: new Date(2022, 11, 17), }, { @@ -174,7 +173,7 @@ export const mockedCourses = [ category: "Farm", rating: 3, progress: 90, - members: 200, + members: [], createdAt: new Date(2022, 11, 17), }, ]; From f838b0382ccfe1c0b1545a1ffa951728815d6f82 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 May 2022 00:53:34 +0300 Subject: [PATCH 28/34] implement confirmation email page --- src/App.jsx | 27 +- src/actions/auth.js | 4 +- src/api/auth.js | 7 +- src/assets/icons/congratulations.svg | 364 ------------------ src/assets/images/congratulations.png | Bin 0 -> 79264 bytes src/assets/images/email.png | Bin 0 -> 23574 bytes src/common/icon/index.jsx | 4 - src/common/image/index.jsx | 28 ++ src/common/input/input-component/index.jsx | 12 + .../auth/buttons-container/index.jsx | 8 + .../auth/buttons-container/styles.scss | 20 + src/components/auth/index.js | 3 + .../auth/inputs-container/index.jsx | 14 + .../auth/inputs-container/styles.scss | 19 + src/components/auth/placeholder/index.jsx | 12 + src/components/auth/placeholder/styles.scss | 19 + src/constants/routes.js | 10 +- src/layout/auth/styles.scss | 6 + src/screens/auth/confirm-email/config.js | 48 +++ src/screens/auth/confirm-email/index.jsx | 124 ++++++ src/screens/auth/index.js | 5 + src/screens/auth/sign-in/index.jsx | 3 +- src/screens/auth/sign-up/config.js | 12 +- src/screens/auth/sign-up/index.jsx | 133 +++---- 24 files changed, 414 insertions(+), 468 deletions(-) delete mode 100644 src/assets/icons/congratulations.svg create mode 100644 src/assets/images/congratulations.png create mode 100644 src/assets/images/email.png create mode 100644 src/common/image/index.jsx create mode 100644 src/components/auth/buttons-container/index.jsx create mode 100644 src/components/auth/buttons-container/styles.scss create mode 100644 src/components/auth/index.js create mode 100644 src/components/auth/inputs-container/index.jsx create mode 100644 src/components/auth/inputs-container/styles.scss create mode 100644 src/components/auth/placeholder/index.jsx create mode 100644 src/components/auth/placeholder/styles.scss create mode 100644 src/screens/auth/confirm-email/config.js create mode 100644 src/screens/auth/confirm-email/index.jsx create mode 100644 src/screens/auth/index.js diff --git a/src/App.jsx b/src/App.jsx index efeb1df5d..49c220d44 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,16 +5,20 @@ import { QueryClientProvider } from "react-query"; import customParseFormat from "dayjs/plugin/customParseFormat"; import { Switch, BrowserRouter as Router, Route } from "react-router-dom"; -import { SignInPage } from "screens/auth/sign-in"; -import { SignUpPage } from "screens/auth/sign-up"; -import { ForgotPasswordPage } from "screens/auth/forgot-password"; -import { AdditionalInfoPage } from "screens/auth/additional-info"; +import { + SignInPage, + SignUpPage, + ConfirmEmailPage, + ForgotPasswordPage, + AdditionalInfoPage, +} from "screens/auth"; import { PrivateRoute } from "components/privateRoute"; import { SideBarNavigation } from "common/side-bar-navigation"; import { Routes } from "routes"; import ScrollToTop from "utils/scrollToTop"; +import { Routes as PathRoutes } from "constants/routes"; import { SearchBarProvider } from "providers/search-bar"; import { queryClient } from "./reactQuery"; @@ -43,14 +47,21 @@ function App() { - - - + + + + diff --git a/src/actions/auth.js b/src/actions/auth.js index 8a7907d1c..32242fe8e 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -98,7 +98,8 @@ export const register = ({ name, password }) => async (dispatch) => { try { - await api.auth.register({ username: name, password }); + const response = await api.auth.register({ username: name, password }); + console.log("register", response); // no auto login for cognito since it needs to confirm email with a code if (!isCognito) { @@ -107,6 +108,7 @@ export const register = return Promise.resolve(); } catch (error) { + console.error("register", error); return Promise.reject(error); } }; diff --git a/src/api/auth.js b/src/api/auth.js index dc367cbee..257ba3c62 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -21,8 +21,11 @@ export const forgotPasswordSubmit = ({ username, code, newPassword }) => newPassword, }); -export const confirmSignup = ({ username, code }) => +export const confirmEmail = ({ email, code }) => apiInstance.post("/users/confirm-sign-up", { - username, code, + username: email, }); + +export const resendEmailCode = ({ email }) => + apiInstance.post("/users/resend-sign-up-code", { username: email }); diff --git a/src/assets/icons/congratulations.svg b/src/assets/icons/congratulations.svg deleted file mode 100644 index 749fff465..000000000 --- a/src/assets/icons/congratulations.svg +++ /dev/null @@ -1,364 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/congratulations.png b/src/assets/images/congratulations.png new file mode 100644 index 0000000000000000000000000000000000000000..1d9e0b385d8066ac8ae36285e668d6555666b0e0 GIT binary patch literal 79264 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

G>ivQA3M`N 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 Nsd`(b8f6vs{{Wcz{3-wd literal 0 HcmV?d00001 diff --git a/src/assets/images/email.png b/src/assets/images/email.png new file mode 100644 index 0000000000000000000000000000000000000000..a7ec5794932361fa3a572a86c23b617b8248886b GIT binary patch literal 23574 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 Z1GX!D(?55VYQDn{-_X+6#Hrha{|B1Yb}j$_ literal 0 HcmV?d00001 diff --git a/src/common/icon/index.jsx b/src/common/icon/index.jsx index 897e30ef8..5fe267259 100644 --- a/src/common/icon/index.jsx +++ b/src/common/icon/index.jsx @@ -41,7 +41,6 @@ import { ReactComponent as GamburgerIcon } from "assets/icons/gamburger.svg"; import { ReactComponent as LogoutIcon } from "assets/icons/logout.svg"; import { ReactComponent as TrashIcon } from "assets/icons/trash.svg"; import { ReactComponent as CropIcon } from "assets/icons/crop.svg"; -import { ReactComponent as CongratulationsIcon } from "assets/icons/congratulations.svg"; import { ReactComponent as UsersIcon } from "assets/icons/users.svg"; import { ReactComponent as SearchIcon } from "assets/icons/search.svg"; import { ReactComponent as PlusIcon } from "assets/icons/plus.svg"; @@ -79,9 +78,6 @@ const getIcon = (iconName) => { case "camera": return ; - case "congratulations": - return ; - case "cross": return ; diff --git a/src/common/image/index.jsx b/src/common/image/index.jsx new file mode 100644 index 000000000..cd489cb90 --- /dev/null +++ b/src/common/image/index.jsx @@ -0,0 +1,28 @@ +import CongratulationsImage from "assets/images/congratulations.png"; +import EmailImage from "assets/images/email.png"; + +const getImageByName = (name) => { + switch (name) { + case "email": + return EmailImage; + + case "congratulations": + return CongratulationsImage; + + default: + return null; + } +}; + +export const Image = ({ image, className }) => { + return ( + + ); +}; diff --git a/src/common/input/input-component/index.jsx b/src/common/input/input-component/index.jsx index 407dc65fc..c0140f5c1 100644 --- a/src/common/input/input-component/index.jsx +++ b/src/common/input/input-component/index.jsx @@ -26,6 +26,18 @@ export const InputComponent = ({ ); } + if (type === "email-code") { + return ( + onChange(event.target.value)} + {...props} + /> + ); + } + if (type === "currency") { return ( ( +

+); diff --git a/src/components/auth/buttons-container/styles.scss b/src/components/auth/buttons-container/styles.scss new file mode 100644 index 000000000..490dec555 --- /dev/null +++ b/src/components/auth/buttons-container/styles.scss @@ -0,0 +1,20 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.auth-action-buttons-container { + width: 100%; + gap: $gap-medium; + @include flexColumn(center, flex-start); + + .buttons-container { + width: 100%; + gap: $gap-medium; + @include flexRow(center, space-between); + } + + @include breakpoint("mobile", "minMax") { + .buttons-container { + @include flexColumn(center, flex-start); + } + } +} diff --git a/src/components/auth/index.js b/src/components/auth/index.js new file mode 100644 index 000000000..3c791531c --- /dev/null +++ b/src/components/auth/index.js @@ -0,0 +1,3 @@ +export { Placeholder } from "./placeholder"; +export { InputsContainer } from "./inputs-container"; +export { ButtonsContainer } from "./buttons-container"; diff --git a/src/components/auth/inputs-container/index.jsx b/src/components/auth/inputs-container/index.jsx new file mode 100644 index 000000000..c546e9d3d --- /dev/null +++ b/src/components/auth/inputs-container/index.jsx @@ -0,0 +1,14 @@ +import { InputField } from "common/input"; + +import "./styles.scss"; + +export const InputsContainer = ({ inputs, children }) => { + return ( +
+ {inputs.map((data, index) => ( + + ))} + {children &&
{children}
} +
+ ); +}; diff --git a/src/components/auth/inputs-container/styles.scss b/src/components/auth/inputs-container/styles.scss new file mode 100644 index 000000000..0216f7e24 --- /dev/null +++ b/src/components/auth/inputs-container/styles.scss @@ -0,0 +1,19 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.auth-inputs-container { + width: 100%; + gap: $gap-medium; + @include flexColumn(center, center); + + .bottom-container { + width: 100%; + gap: $gap-smallest; + @include flexRow(center, space-between); + + button { + height: 24px; + width: fit-content; + } + } +} diff --git a/src/components/auth/placeholder/index.jsx b/src/components/auth/placeholder/index.jsx new file mode 100644 index 000000000..3d4f1a11d --- /dev/null +++ b/src/components/auth/placeholder/index.jsx @@ -0,0 +1,12 @@ +import { Image } from "common/image"; + +import "./styles.scss"; + +export const Placeholder = ({ image, title }) => { + return ( +
+ {image && } + {title &&
{title}
} +
+ ); +}; diff --git a/src/components/auth/placeholder/styles.scss b/src/components/auth/placeholder/styles.scss new file mode 100644 index 000000000..a414893e8 --- /dev/null +++ b/src/components/auth/placeholder/styles.scss @@ -0,0 +1,19 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.auth-placeholder-container { + width: 100%; + gap: $gap-big; + @include flexColumn(center, center); + + img { + max-height: 256px; + object-fit: contain; + } + + @include breakpoint("mobile") { + img { + max-height: unset; + } + } +} diff --git a/src/constants/routes.js b/src/constants/routes.js index 676e61b51..224ed43a7 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -1,6 +1,14 @@ const news = "/news"; const courses = "/courses"; +const Auth = { + Login: "/login", + Register: "/register", + ConfirmEmail: "/confirm-email", + ForgotPassword: "/forgot-password", + AdditionalInfo: "/additional-info", +}; + const News = { Home: news, Article: `${news}/:id`, @@ -16,4 +24,4 @@ const Courses = { Members: `${courses}/:id/members`, }; -export const Routes = { News, Courses }; +export const Routes = { Auth, News, Courses }; diff --git a/src/layout/auth/styles.scss b/src/layout/auth/styles.scss index 9ab670077..0a1e39fe0 100644 --- a/src/layout/auth/styles.scss +++ b/src/layout/auth/styles.scss @@ -72,6 +72,12 @@ display: flex; align-items: center; justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } } .row-container { diff --git a/src/screens/auth/confirm-email/config.js b/src/screens/auth/confirm-email/config.js new file mode 100644 index 000000000..c5a680b7b --- /dev/null +++ b/src/screens/auth/confirm-email/config.js @@ -0,0 +1,48 @@ +import * as yup from "yup"; + +const model = { + code: { + name: "code", + required: true, + type: "email-code", + placeholder: "Code", + }, +}; + +export const inputs = [model.code]; + +export const validationSchema = yup.object().shape({ + [model.code.name]: yup + .string() + .test({ + name: "email-code", + message: "Must be 6 digits", + test: (value) => !value.includes("_"), + }) + .required("Code is required"), +}); + +export const initialValues = { + [model.code.name]: "", +}; + +export const Variant = { + Confirm: "Confirm", + Success: "Success", +}; + +export const Image = { + [Variant.Confirm]: "email", + [Variant.Success]: "congratulations", +}; + +export const Title = { + [Variant.Confirm]: "Sign Up", + [Variant.Success]: "Congratulations!", +}; + +export const Subtitle = { + [Variant.Success]: null, + [Variant.Confirm]: + "Please check your mail. Enter the code you received in the email.", +}; diff --git a/src/screens/auth/confirm-email/index.jsx b/src/screens/auth/confirm-email/index.jsx new file mode 100644 index 000000000..74472e468 --- /dev/null +++ b/src/screens/auth/confirm-email/index.jsx @@ -0,0 +1,124 @@ +import { useEffect, useState, useCallback } from "react"; +import { useAlert } from "react-alert"; +import { useHistory } from "react-router-dom"; + +import { AuthLayout } from "layout/auth"; +import { ActionButton } from "common/buttons/action-button"; +import { + Placeholder, + InputsContainer, + ButtonsContainer, +} from "components/auth"; + +import { api } from "api"; +import { Routes } from "constants/routes"; +import { getErrorMessage } from "utils/error"; + +import { + Title, + Image, + inputs, + Variant, + Subtitle, + initialValues, + validationSchema, +} from "./config"; + +export const ConfirmEmailPage = () => { + const alert = useAlert(); + const history = useHistory(); + + const [email, setEmail] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [variant, setVariant] = useState(Variant.Confirm); + const [isFromRegister, setIsFromRegister] = useState(true); + + useEffect(() => { + const state = history.location?.state || {}; + + if (state.email) { + setEmail(state.email); + setIsFromRegister(state.isFromRegister); + } else { + history.replace(Routes.Auth.Login); + } + }, [history.location]); + + const handleSubmit = useCallback( + async ({ code }) => { + try { + setIsLoading(true); + await api.auth.confirmEmail({ email, code }); + setVariant(Variant.Success); + } catch (error) { + alert.error(getErrorMessage(error)); + } finally { + setIsLoading(false); + } + }, + [email] + ); + + const handleResendClick = useCallback(async () => { + try { + setIsLoading(true); + await api.auth.resendEmailCode({ email }); + alert.success(`We've sent new confirmation code at ${email}`); + } catch (error) { + alert.error(getErrorMessage(error)); + } finally { + setIsLoading(false); + } + }, [email]); + + return ( + + {({ dirty }) => ( + <> + + + {variant === Variant.Confirm && ( + <> + +
+ + + + + + )} + + {variant === Variant.Success && isFromRegister && ( + + history.replace(Routes.News.Home)} + /> + history.replace(Routes.Auth.AdditionalInfo)} + /> + + )} + + )} + + ); +}; diff --git a/src/screens/auth/index.js b/src/screens/auth/index.js new file mode 100644 index 000000000..dc16da28d --- /dev/null +++ b/src/screens/auth/index.js @@ -0,0 +1,5 @@ +export { SignUpPage } from "./sign-up"; +export { SignInPage } from "./sign-in"; +export { ConfirmEmailPage } from "./confirm-email"; +export { ForgotPasswordPage } from "./forgot-password"; +export { AdditionalInfoPage } from "./additional-info"; diff --git a/src/screens/auth/sign-in/index.jsx b/src/screens/auth/sign-in/index.jsx index d0d0d10f5..a695c032e 100644 --- a/src/screens/auth/sign-in/index.jsx +++ b/src/screens/auth/sign-in/index.jsx @@ -10,6 +10,7 @@ import { Checkbox } from "common/checkbox"; import { ActionButton } from "common/buttons/action-button"; import { login } from "actions/auth"; +import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; import { model, validationSchema, initialValues } from "./config"; @@ -36,7 +37,7 @@ export const SignInPage = () => { try { setIsLoading(true); await login({ name: username, password })(dispatch); - history.push("/news"); + history.push(Routes.News.Home); } catch (error) { setIsLoading(false); alert.error(getErrorMessage(error)); diff --git a/src/screens/auth/sign-up/config.js b/src/screens/auth/sign-up/config.js index bb3ce8013..fffbe43c5 100644 --- a/src/screens/auth/sign-up/config.js +++ b/src/screens/auth/sign-up/config.js @@ -1,11 +1,11 @@ import * as Yup from "yup"; export const model = { - username: { - name: "username", + email: { + name: "email", icon: "person", required: true, - placeholder: "Username", + placeholder: "Email", }, password: { @@ -21,7 +21,9 @@ export const model = { }; export const validationSchema = Yup.object().shape({ - [model.username.name]: Yup.string().required("Username is required field!"), + [model.email.name]: Yup.string() + .email("Email is not valid") + .required("Username is required field!"), [model.password.name]: Yup.string() .min(8, "Password must be at least 8 characters") .required("Password is required field!"), @@ -29,7 +31,7 @@ export const validationSchema = Yup.object().shape({ }); export const initialValues = { - [model.username.name]: "", + [model.email.name]: "", [model.password.name]: "", [model.agrre.name]: false, }; diff --git a/src/screens/auth/sign-up/index.jsx b/src/screens/auth/sign-up/index.jsx index 527a61c9b..c39df3f47 100644 --- a/src/screens/auth/sign-up/index.jsx +++ b/src/screens/auth/sign-up/index.jsx @@ -3,14 +3,15 @@ import { useAlert } from "react-alert"; import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; -import { Icon } from "common/icon"; import { TextLink } from "common/links"; import { AuthLayout } from "layout/auth"; import { InputField } from "common/input"; import { CheckboxField } from "common/checkbox"; +import { ButtonsContainer } from "components/auth"; import { ActionButton } from "common/buttons/action-button"; import { register } from "actions/auth"; +import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; import { model, validationSchema, initialValues } from "./config"; @@ -21,7 +22,6 @@ export const SignUpPage = () => { const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); - const [isSidngedUp, setIsSignedUp] = useState(false); const onGoogleLogin = () => { // Auth.federatedSignIn({ provider: "Google" }); @@ -31,105 +31,74 @@ export const SignUpPage = () => { // Auth.federatedSignIn({ provider: "Facebook" }); }; - const handleFormSubmit = async ({ username, password }) => { + const handleFormSubmit = async ({ email, password }) => { try { setIsLoading(true); - await register({ name: username, password })(dispatch); - setIsSignedUp(true); + await register({ name: email, password })(dispatch); + setIsLoading(false); + + history.push({ + pathname: Routes.Auth.ConfirmEmail, + state: { email, isFromRegister: true }, + }); } catch (error) { - alert.error(getErrorMessage(error)); - } finally { setIsLoading(false); + alert.error(getErrorMessage(error)); } }; return ( {() => ( <> - {isSidngedUp && ( - <> -
- -
- -
- history.replace("/news")} - /> - - history.replace("/additional-info")} - /> -
- - )} - - {!isSidngedUp && ( - <> -
- - - -
-
- - -
-

I agree with

- -
-
-
-
- - - -
-
Sign In with services
- -
- - - + + + +
+
+ + +
+

I agree with

+
- -
-
Already have an account?
- -
- - )} +
+
+ + + + + + + + + +
+
Already have an account?
+ +
)} From 5ef8678c2e64de19bab2d5239c3ede95c71048c4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 May 2022 01:55:21 +0300 Subject: [PATCH 29/34] refactoring --- src/common/checkbox/index.jsx | 15 ++- src/common/checkbox/styles.scss | 25 ++--- .../auth/footer-container/index.jsx | 8 ++ .../auth/footer-container/styles.scss | 12 +++ src/components/auth/index.js | 1 + src/layout/auth/styles.scss | 97 ++----------------- src/screens/auth/additional-info/index.jsx | 14 +-- src/screens/auth/confirm-email/config.js | 11 +-- src/screens/auth/forgot-password/config.js | 46 +++++---- src/screens/auth/forgot-password/index.jsx | 61 ++++++------ src/screens/auth/sign-in/config.js | 5 +- src/screens/auth/sign-in/index.jsx | 84 ++++++++-------- src/screens/auth/sign-up/config.js | 9 +- src/screens/auth/sign-up/index.jsx | 42 ++++---- src/utils/validators.js | 24 +++++ 15 files changed, 209 insertions(+), 245 deletions(-) create mode 100644 src/components/auth/footer-container/index.jsx create mode 100644 src/components/auth/footer-container/styles.scss create mode 100644 src/utils/validators.js diff --git a/src/common/checkbox/index.jsx b/src/common/checkbox/index.jsx index bd89a5042..3ac463feb 100644 --- a/src/common/checkbox/index.jsx +++ b/src/common/checkbox/index.jsx @@ -6,7 +6,13 @@ import { Icon } from "common/icon"; import "./styles.scss"; -export const Checkbox = ({ value = false, onChange, title, error }) => { +export const Checkbox = ({ + title, + error, + onChange, + children, + value = false, +}) => { return (
@@ -15,7 +21,12 @@ export const Checkbox = ({ value = false, onChange, title, error }) => {
- {title &&

{title}

} + {(title || children) && ( +
+ {title &&
{title}
} + {children && children} +
+ )}
); }; diff --git a/src/common/checkbox/styles.scss b/src/common/checkbox/styles.scss index 1e348aa1d..2df7f31aa 100644 --- a/src/common/checkbox/styles.scss +++ b/src/common/checkbox/styles.scss @@ -1,12 +1,13 @@ +@import "src/scss/mixins"; @import "src/scss/variables"; .pf-checkbox { cursor: pointer; user-select: none; - display: flex; - align-items: center; - flex-direction: row; + height: 24px; + gap: $gap-smallest; + @include flexRow(center, center); input[type="checkbox"] { height: 0; @@ -37,17 +38,19 @@ &-error { border-color: #da3443; } - } - &:hover .checkbox { - border-color: #cadcd6; + &:hover { + border-color: #cadcd6; + } } - .checkbox-title { - color: #eeefef; - margin-left: 10px; - user-select: none; - font: $font-body; + .checkbox-title-container { + gap: 4px; + @include flexRow(center, flex-start); + + h5 { + user-select: none; + } } input[type="checkbox"]:checked + .checkbox { diff --git a/src/components/auth/footer-container/index.jsx b/src/components/auth/footer-container/index.jsx new file mode 100644 index 000000000..1bdbc9fc1 --- /dev/null +++ b/src/components/auth/footer-container/index.jsx @@ -0,0 +1,8 @@ +import "./styles.scss"; + +export const FooterContainer = ({ title, children }) => ( +
+ {title &&
{title}
} + {children && children} +
+); diff --git a/src/components/auth/footer-container/styles.scss b/src/components/auth/footer-container/styles.scss new file mode 100644 index 000000000..0b3f51d5e --- /dev/null +++ b/src/components/auth/footer-container/styles.scss @@ -0,0 +1,12 @@ +@import "src/scss/mixins"; +@import "src/scss/variables"; + +.auth-footer-container { + width: 100%; + gap: $gap-smallest; + @include flexRow(center, center); + + @include breakpoint("mobile", "minMax") { + @include flexColumn(center, center); + } +} diff --git a/src/components/auth/index.js b/src/components/auth/index.js index 3c791531c..094ec4105 100644 --- a/src/components/auth/index.js +++ b/src/components/auth/index.js @@ -1,3 +1,4 @@ export { Placeholder } from "./placeholder"; export { InputsContainer } from "./inputs-container"; +export { FooterContainer } from "./footer-container"; export { ButtonsContainer } from "./buttons-container"; diff --git a/src/layout/auth/styles.scss b/src/layout/auth/styles.scss index 0a1e39fe0..1479ca067 100644 --- a/src/layout/auth/styles.scss +++ b/src/layout/auth/styles.scss @@ -7,8 +7,7 @@ overflow: hidden; background-color: transparent; - display: flex; - flex-direction: row; + @include flexRow(); .left-container { width: 40%; @@ -18,26 +17,19 @@ overflow-y: scroll; background-color: rgba(20, 20, 20, 0.6); - display: flex; - flex-direction: column; - align-items: center; + @include flexColumn(center, auto); .auth-scroll-container { gap: 80px; width: 80%; - - display: flex; - flex-direction: column; - justify-content: flex-start; + padding-bottom: 16px; + @include flexColumn(auto, flex-start); .logo-container { width: 100%; height: 120px; flex-shrink: 0; - - display: flex; - align-items: flex-end; - justify-content: flex-start; + @include flexColumn(flex-start, flex-end); .left-logo-icon { width: 100%; @@ -57,72 +49,7 @@ .form-container { gap: 40px; - display: flex; - flex-direction: column; - - .inputs-container { - gap: 24px; - display: flex; - flex-direction: column; - } - - .image-container { - width: 100%; - padding: 12px 24px; - display: flex; - align-items: center; - justify-content: center; - - img { - width: 100%; - height: 100%; - object-fit: contain; - } - } - - .row-container { - gap: 24px; - width: 100%; - display: flex; - flex-direction: row; - justify-content: space-between; - } - - .terms-checkbox-container { - display: flex; - flex-direction: row; - - .link-container { - display: flex; - align-items: center; - flex-direction: row; - - p { - user-select: none; - color: #eeefef; - font: $font-body; - margin-left: 10px; - margin-right: 4px; - } - } - } - - .socials-container { - width: 100%; - gap: 24px; - display: flex; - align-items: center; - flex-direction: column; - } - - .footer { - gap: 8px; - margin-bottom: 10px; - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: center; - } + @include flexColumn(); } } } @@ -134,8 +61,7 @@ background-color: transparent; display: flex; - align-items: center; - justify-content: center; + @include flexColumn(center, center); .right-logo-icon { width: 80%; @@ -173,8 +99,7 @@ .logo-container { height: 72px; - align-items: center; - justify-content: center; + @include flexColumn(center, center); .left-logo-icon { height: 24px; @@ -206,12 +131,6 @@ .auth-scroll-container { width: 100%; padding: 0px 16px; - - .form-container { - .row-container { - flex-wrap: wrap; - } - } } } } diff --git a/src/screens/auth/additional-info/index.jsx b/src/screens/auth/additional-info/index.jsx index 151b6765a..c2ae97c70 100644 --- a/src/screens/auth/additional-info/index.jsx +++ b/src/screens/auth/additional-info/index.jsx @@ -4,9 +4,9 @@ import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; import { AuthLayout } from "layout/auth"; -import { InputField } from "common/input"; import { DragDropZoneField } from "common/drag-drop-zone"; import { ActionButton } from "common/buttons/action-button"; +import { InputsContainer, ButtonsContainer } from "components/auth"; import { getErrorMessage } from "utils/error"; import { updateUserInfo } from "actions/userAction"; @@ -71,19 +71,13 @@ export const AdditionalInfoPage = () => { > {({ dirty }) => ( <> - {step === AdditionalStep.Info && ( -
- {inputs.map((item) => ( - - ))} -
- )} + {step === AdditionalStep.Info && } {step === AdditionalStep.Avatar && ( )} -
+ { disabled={!dirty} variant="primary" /> -
+ )}
diff --git a/src/screens/auth/confirm-email/config.js b/src/screens/auth/confirm-email/config.js index c5a680b7b..b389ee167 100644 --- a/src/screens/auth/confirm-email/config.js +++ b/src/screens/auth/confirm-email/config.js @@ -1,5 +1,7 @@ import * as yup from "yup"; +import { emailCode } from "utils/validators"; + const model = { code: { name: "code", @@ -12,14 +14,7 @@ const model = { export const inputs = [model.code]; export const validationSchema = yup.object().shape({ - [model.code.name]: yup - .string() - .test({ - name: "email-code", - message: "Must be 6 digits", - test: (value) => !value.includes("_"), - }) - .required("Code is required"), + [model.code.name]: emailCode.required("Code is required"), }); export const initialValues = { diff --git a/src/screens/auth/forgot-password/config.js b/src/screens/auth/forgot-password/config.js index 1adc9f37f..50c7f3790 100644 --- a/src/screens/auth/forgot-password/config.js +++ b/src/screens/auth/forgot-password/config.js @@ -1,5 +1,7 @@ import * as Yup from "yup"; +import { password, repeatPassword } from "utils/validators"; + export const model = { username: { name: "username", @@ -19,53 +21,49 @@ export const model = { password: { name: "password", + type: "password", icon: "lock", placeholder: "New Password", }, confirmPassword: { name: "confirmPassword", + type: "password", icon: "lock", placeholder: "Confirm Password", }, }; -const { username, codeRequested, code, password, confirmPassword } = model; - const isRequiredField = (value, schema, message) => value ? schema.required(message) : schema.optional(); export const validationSchema = Yup.object().shape({ - [username.name]: Yup.string().required("Username is required field!"), + [model.username.name]: Yup.string().required("Username is required field!"), - [codeRequested.name]: Yup.bool().optional(), + [model.codeRequested.name]: Yup.bool().optional(), - [code.name]: Yup.string().when([codeRequested.name], (value, schema) => - isRequiredField(value, schema, "Code is required field!") + [model.code.name]: Yup.string().when( + [model.codeRequested.name], + (value, schema) => isRequiredField(value, schema, "Code is required field!") ), - [password.name]: Yup.string() - .min(6, "Password must be at least 6 characters") - .when([codeRequested.name], (value, schema) => + [model.password.name]: password(8).when( + [model.codeRequested.name], + (value, schema) => isRequiredField(value, schema, "Password is required field!") - ), + ), - [confirmPassword.name]: Yup.string() - .min(6, "Password must be at least 6 characters") - .test( - "passwords-match", - "Passwords must match", - (value, context) => context.parent.password === value - ) - .when([codeRequested.name], (value, schema) => + [model.confirmPassword.name]: repeatPassword(8).when( + [model.codeRequested.name], + (value, schema) => isRequiredField(value, schema, "Confirm Password is required field!") - ), + ), }); export const initialValues = { - [username.name]: "", - [codeRequested.name]: false, - [code.name]: "", - [password.name]: "", - [confirmPassword.name]: "", + [model.code.name]: "", + [model.username.name]: "", + [model.password.name]: "", + [model.confirmPassword.name]: "", + [model.codeRequested.name]: false, }; diff --git a/src/screens/auth/forgot-password/index.jsx b/src/screens/auth/forgot-password/index.jsx index 8388d4759..2ff923f69 100644 --- a/src/screens/auth/forgot-password/index.jsx +++ b/src/screens/auth/forgot-password/index.jsx @@ -6,6 +6,11 @@ import { TextLink } from "common/links"; import { AuthLayout } from "layout/auth"; import { InputField } from "common/input"; import { ActionButton } from "common/buttons/action-button"; +import { + FooterContainer, + InputsContainer, + ButtonsContainer, +} from "components/auth"; import { getErrorMessage } from "utils/error"; import { requestCode, resetPassword } from "actions/auth"; @@ -73,6 +78,13 @@ export const ForgotPasswordPage = () => { } }; + const generateInputs = (values) => { + const { username, code, password, confirmPassword } = model; + return isCodeRequested(values) + ? [username, code, password, confirmPassword] + : [username]; + }; + return ( { > {({ values, setFieldValue }) => ( <> -
- - - {values[model.codeRequested.name] && ( - <> - - - - - )} - -
- handleCodeResend(values, setFieldValue)} - /> - - -
-
- -
-
Go back to
+ + + + handleCodeResend(values, setFieldValue)} + /> + + + + + -
+ )}
diff --git a/src/screens/auth/sign-in/config.js b/src/screens/auth/sign-in/config.js index b63e699fb..8b4c234d4 100644 --- a/src/screens/auth/sign-in/config.js +++ b/src/screens/auth/sign-in/config.js @@ -5,13 +5,14 @@ export const model = { name: "username", icon: "person", required: true, - placeholder: "Username", + placeholder: "Username or Email", }, password: { name: "password", icon: "lock", required: true, + type: "password", placeholder: "Password", }, }; @@ -25,3 +26,5 @@ export const initialValues = { [model.username.name]: "", [model.password.name]: "", }; + +export const inputs = [model.username, model.password]; diff --git a/src/screens/auth/sign-in/index.jsx b/src/screens/auth/sign-in/index.jsx index a695c032e..45d3617b6 100644 --- a/src/screens/auth/sign-in/index.jsx +++ b/src/screens/auth/sign-in/index.jsx @@ -5,15 +5,19 @@ import { useHistory } from "react-router-dom"; import { TextLink } from "common/links"; import { AuthLayout } from "layout/auth"; -import { InputField } from "common/input"; import { Checkbox } from "common/checkbox"; import { ActionButton } from "common/buttons/action-button"; +import { + InputsContainer, + FooterContainer, + ButtonsContainer, +} from "components/auth"; import { login } from "actions/auth"; import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; -import { model, validationSchema, initialValues } from "./config"; +import { validationSchema, initialValues, inputs } from "./config"; // TODO: Implement Remember me; @@ -39,6 +43,13 @@ export const SignInPage = () => { await login({ name: username, password })(dispatch); history.push(Routes.News.Home); } catch (error) { + // TODO: Navigate to ConfirmEmail if email is not confirmed; + /* + history.push({ + pathname: Routes.Auth.ConfirmEmail, + state: { email, isFromRegister: false } + }) + */ setIsLoading(false); alert.error(getErrorMessage(error)); } @@ -54,58 +65,47 @@ export const SignInPage = () => { > {() => ( <> -
- - - -
- setRemember(!remember)} - /> + + setRemember(!remember)} + /> - -
-
+ + -
-
Sign In with services
- -
- - - -
-
+ + -
-
Don't have an account yet?
+ + + -
+ )} diff --git a/src/screens/auth/sign-up/config.js b/src/screens/auth/sign-up/config.js index fffbe43c5..7e08e7274 100644 --- a/src/screens/auth/sign-up/config.js +++ b/src/screens/auth/sign-up/config.js @@ -1,5 +1,7 @@ import * as Yup from "yup"; +import { password } from "utils/validators"; + export const model = { email: { name: "email", @@ -10,6 +12,7 @@ export const model = { password: { name: "password", + type: "password", icon: "lock", required: true, placeholder: "Password", @@ -20,13 +23,13 @@ export const model = { }, }; +export const inputs = [model.email, model.password]; + export const validationSchema = Yup.object().shape({ [model.email.name]: Yup.string() .email("Email is not valid") .required("Username is required field!"), - [model.password.name]: Yup.string() - .min(8, "Password must be at least 8 characters") - .required("Password is required field!"), + [model.password.name]: password(8).required("Password is required field!"), [model.agrre.name]: Yup.bool().isTrue().required(), }); diff --git a/src/screens/auth/sign-up/index.jsx b/src/screens/auth/sign-up/index.jsx index c39df3f47..1a8376e76 100644 --- a/src/screens/auth/sign-up/index.jsx +++ b/src/screens/auth/sign-up/index.jsx @@ -5,16 +5,19 @@ import { useHistory } from "react-router-dom"; import { TextLink } from "common/links"; import { AuthLayout } from "layout/auth"; -import { InputField } from "common/input"; import { CheckboxField } from "common/checkbox"; -import { ButtonsContainer } from "components/auth"; import { ActionButton } from "common/buttons/action-button"; +import { + FooterContainer, + InputsContainer, + ButtonsContainer, +} from "components/auth"; import { register } from "actions/auth"; import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; -import { model, validationSchema, initialValues } from "./config"; +import { model, validationSchema, initialValues, inputs } from "./config"; export const SignUpPage = () => { const alert = useAlert(); @@ -57,25 +60,15 @@ export const SignUpPage = () => { > {() => ( <> -
- - - -
-
- - -
-

I agree with

- -
-
-
-
+ + + + + @@ -95,10 +88,9 @@ export const SignUpPage = () => { />
-
-
Already have an account?
+ -
+ )} diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 000000000..fd08fb728 --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,24 @@ +import * as yup from "yup"; + +export const emailCode = yup.string().test({ + name: "email-code", + message: "Must be 6 digits", + test: (value) => !value.includes("_"), +}); + +export const password = (characters) => { + const message = `Password must be at least ${characters} characters`; + return yup.string().min(characters, message); +}; + +export const repeatPassword = (characters) => { + const message = `Password must be at least ${characters} characters`; + return yup + .string() + .min(characters, message) + .test( + "passwords-match", + "Passwords must match", + (value, context) => context.parent.password === value + ); +}; From f22f8503b4b31e61d0a963b6a5974ac02cd580cf Mon Sep 17 00:00:00 2001 From: Neil Palima Date: Fri, 6 May 2022 22:53:21 +0800 Subject: [PATCH 30/34] fixes to detect users for sign up confirmation --- api/src/controllers/userController.js | 9 ++++++--- package.json | 2 +- src/actions/auth.js | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/userController.js b/api/src/controllers/userController.js index 8306594e6..d8c3d00e6 100644 --- a/api/src/controllers/userController.js +++ b/api/src/controllers/userController.js @@ -155,15 +155,18 @@ const registerUser = async (req, res) => { }) } + const responseData = {} + if (isCognito) { await registerCognito(username, password) + responseData.message = 'Code for sign up confirmation was sent to your email' + responseData.forSignUpConfirmation = true } else { await registerLocal(username, password) + responseData.message = 'The user has been registered' } - res.status(201).send({ - message: 'The user has been registered' - }) + res.status(201).send(responseData) } catch (err) { res.status(409).json({ error: err.message }) } diff --git a/package.json b/package.json index e7cd96ad6..de507d0a0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", - "aws-amplify": "^3.3.27", + "aws-amplify": "^4.3.21", "axios": "^0.21.1", "classnames": "^2.3.1", "dayjs": "^1.10.7", diff --git a/src/actions/auth.js b/src/actions/auth.js index 32242fe8e..5f8d23879 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -65,7 +65,19 @@ export const login = let authData = {}; let response; if (isCognito) { - response = await Auth.signIn(name, password); + try { + // try to use formatted username + const username = name.replace(/@/g, ""); + response = await Auth.signIn(username, password); + } catch (error) { + if (error.code === "UserNotConfirmedException") { + // TODO: add handling unconfirmed user + throw error; + } + // try non formatted email + response = await Auth.signIn(name, password); + } + const id = response?.attributes?.sub || ""; authData = { id, @@ -104,6 +116,8 @@ export const register = // no auto login for cognito since it needs to confirm email with a code if (!isCognito) { await login({ name, password })(dispatch); + } else if (response.data.forSignUpConfirmation) { + // TODO: add handling for sign up confirmation } return Promise.resolve(); From 2bd6e57ba391eb53fba4b6ad6efd2639de0b12b4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 9 May 2022 21:58:24 +0300 Subject: [PATCH 31/34] implement non confirmed flow --- src/actions/auth.js | 99 +++++++++++++++++------- src/screens/auth/confirm-email/config.js | 2 +- src/screens/auth/confirm-email/index.jsx | 46 +++++++---- src/screens/auth/sign-in/helpers.js | 3 + src/screens/auth/sign-in/index.jsx | 23 +++--- src/screens/auth/sign-up/index.jsx | 28 +++++-- src/store/user/thunks.js | 65 ++++++++++++++++ 7 files changed, 206 insertions(+), 60 deletions(-) create mode 100644 src/screens/auth/sign-in/helpers.js diff --git a/src/actions/auth.js b/src/actions/auth.js index 5f8d23879..b7a09afa0 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -57,55 +57,96 @@ export const getAccessToken = () => async (dispatch) => { } }; -// TODO: Move to store/thunk when reduxjs/toolkit will be setuped export const login = ({ name, password }) => async (dispatch) => { try { - let authData = {}; + // TODO: Set isLoading + let response; + let data = {}; + let username = name; + if (isCognito) { - try { - // try to use formatted username - const username = name.replace(/@/g, ""); - response = await Auth.signIn(username, password); - } catch (error) { - if (error.code === "UserNotConfirmedException") { - // TODO: add handling unconfirmed user - throw error; - } - // try non formatted email - response = await Auth.signIn(name, password); - } - - const id = response?.attributes?.sub || ""; - authData = { - id, + if (username.includes("@")) username = username.replace(/@/g, ""); + response = await Auth.signIn(username, password); + + data = { + id: response?.attributes?.sub || "", token: response?.signInUserSession?.idToken?.jwtToken || "", }; - await api.auth.login({ id }); - } else { - response = await api.auth.login({ username: name, password }); - authData = response.data; + + await api.auth.login({ id: data.id }); } - localStorage.setItem("userInfo", JSON.stringify(authData)); + if (!isCognito) { + response = await api.auth.login({ username, password }); + data = { ...response.data }; + } - const profile = await api.user.get({ id: authData.id }); - dispatch(setCurrentUser({ ...response, ...profile?.data?.results })); + localStorage.setItem("userInfo", JSON.stringify(data)); - // await getAccessToken()(dispatch); // access token is already received in login response - // const community = await news()(dispatch); - // await visitCommunity(community.id)(dispatch); + const user = await api.user.get({ id: data.id }); dispatch({ type: USER_LOGIN_SUCCESS, payload: response }); + dispatch(setCurrentUser({ ...response, ...user?.data?.results })); + return Promise.resolve(response); } catch (error) { - dispatch({ type: USER_LOGIN_FAIL, payload: getErrorMessage(error) }); return Promise.reject(error); } }; +// TODO: Move to store/thunk when reduxjs/toolkit will be setuped +// export const login = +// ({ name, password }) => +// async (dispatch) => { +// try { +// let authData = {}; +// let response; +// if (isCognito) { +// try { +// // try to use formatted username +// const username = name.replace(/@/g, ""); +// response = await Auth.signIn(username, password); +// } catch (error) { +// console.log(error); +// if (error.code === "UserNotConfirmedException") { +// // TODO: add handling unconfirmed user +// throw error; +// } +// // try non formatted email +// response = await Auth.signIn(name, password); +// } + +// const id = response?.attributes?.sub || ""; +// authData = { +// id, +// token: response?.signInUserSession?.idToken?.jwtToken || "", +// }; +// await api.auth.login({ id }); +// } else { +// response = await api.auth.login({ username: name, password }); +// authData = response.data; +// } + +// localStorage.setItem("userInfo", JSON.stringify(authData)); + +// const profile = await api.user.get({ id: authData.id }); +// dispatch(setCurrentUser({ ...response, ...profile?.data?.results })); + +// // await getAccessToken()(dispatch); // access token is already received in login response +// // const community = await news()(dispatch); +// // await visitCommunity(community.id)(dispatch); + +// dispatch({ type: USER_LOGIN_SUCCESS, payload: response }); +// return Promise.resolve(response); +// } catch (error) { +// dispatch({ type: USER_LOGIN_FAIL, payload: getErrorMessage(error) }); +// return Promise.reject(error); +// } +// }; + export const register = ({ name, password }) => async (dispatch) => { diff --git a/src/screens/auth/confirm-email/config.js b/src/screens/auth/confirm-email/config.js index b389ee167..82be2d282 100644 --- a/src/screens/auth/confirm-email/config.js +++ b/src/screens/auth/confirm-email/config.js @@ -39,5 +39,5 @@ export const Title = { export const Subtitle = { [Variant.Success]: null, [Variant.Confirm]: - "Please check your mail. Enter the code you received in the email.", + "Please check your mail. Enter the code you received at the email.", }; diff --git a/src/screens/auth/confirm-email/index.jsx b/src/screens/auth/confirm-email/index.jsx index 74472e468..b861a3fa0 100644 --- a/src/screens/auth/confirm-email/index.jsx +++ b/src/screens/auth/confirm-email/index.jsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback } from "react"; import { useAlert } from "react-alert"; +import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; import { AuthLayout } from "layout/auth"; @@ -13,6 +14,7 @@ import { import { api } from "api"; import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; +import { loginThunk } from "store/user/thunks"; import { Title, @@ -27,49 +29,62 @@ import { export const ConfirmEmailPage = () => { const alert = useAlert(); const history = useHistory(); + const dispatch = useDispatch(); - const [email, setEmail] = useState(""); + // const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); const [variant, setVariant] = useState(Variant.Confirm); const [isFromRegister, setIsFromRegister] = useState(true); + const [data, setData] = useState({ email: "", password: "" }); useEffect(() => { const state = history.location?.state || {}; - if (state.email) { - setEmail(state.email); - setIsFromRegister(state.isFromRegister); - } else { + if (!state.email) { history.replace(Routes.Auth.Login); + return; } + + setVariant(state.variant || Variant.Confirm); + setIsFromRegister(state.isFromRegister || false); + setData({ email: state.email || "", password: state.password || "" }); }, [history.location]); const handleSubmit = useCallback( async ({ code }) => { try { setIsLoading(true); - await api.auth.confirmEmail({ email, code }); - setVariant(Variant.Success); + + await api.auth.confirmEmail({ email: data.email, code }); + await dispatch( + loginThunk({ + name: data.email, + password: data.password, + }) + ); + + if (isFromRegister) setVariant(Variant.Success); + else history.push(Routes.News.Home); + + setIsLoading(false); } catch (error) { alert.error(getErrorMessage(error)); - } finally { - setIsLoading(false); } }, - [email] + [data, isFromRegister] ); const handleResendClick = useCallback(async () => { try { setIsLoading(true); - await api.auth.resendEmailCode({ email }); - alert.success(`We've sent new confirmation code at ${email}`); + await api.auth.resendEmailCode({ email: data.email }); + alert.success(`We've sent new confirmation code at ${data.email}`); } catch (error) { alert.error(getErrorMessage(error)); } finally { setIsLoading(false); } - }, [email]); + }, [data.email]); return ( { > {({ dirty }) => ( <> - + {variant === Variant.Confirm && ( <> diff --git a/src/screens/auth/sign-in/helpers.js b/src/screens/auth/sign-in/helpers.js new file mode 100644 index 000000000..3d75da3b8 --- /dev/null +++ b/src/screens/auth/sign-in/helpers.js @@ -0,0 +1,3 @@ +export const isNonConfirmedError = (error) => { + return error && error.code === "UserNotConfirmedException"; +}; diff --git a/src/screens/auth/sign-in/index.jsx b/src/screens/auth/sign-in/index.jsx index 45d3617b6..94c99a0b8 100644 --- a/src/screens/auth/sign-in/index.jsx +++ b/src/screens/auth/sign-in/index.jsx @@ -13,10 +13,13 @@ import { ButtonsContainer, } from "components/auth"; -import { login } from "actions/auth"; +import { api } from "api"; +// import { login } from "actions/auth"; +import { loginThunk } from "store/user/thunks"; import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; +import { isNonConfirmedError } from "./helpers"; import { validationSchema, initialValues, inputs } from "./config"; // TODO: Implement Remember me; @@ -40,18 +43,20 @@ export const SignInPage = () => { const handleFormSubmit = async ({ username, password }) => { try { setIsLoading(true); - await login({ name: username, password })(dispatch); + await dispatch(loginThunk({ name: username, password })); history.push(Routes.News.Home); + setIsLoading(false); } catch (error) { - // TODO: Navigate to ConfirmEmail if email is not confirmed; - /* - history.push({ - pathname: Routes.Auth.ConfirmEmail, - state: { email, isFromRegister: false } - }) - */ setIsLoading(false); alert.error(getErrorMessage(error)); + + if (isNonConfirmedError(error)) { + await api.auth.resendEmailCode({ email: username }); + history.push({ + pathname: Routes.Auth.ConfirmEmail, + state: { email: username, password, isFromRegister: false }, + }); + } } }; diff --git a/src/screens/auth/sign-up/index.jsx b/src/screens/auth/sign-up/index.jsx index 1a8376e76..2fd056ece 100644 --- a/src/screens/auth/sign-up/index.jsx +++ b/src/screens/auth/sign-up/index.jsx @@ -13,9 +13,10 @@ import { ButtonsContainer, } from "components/auth"; -import { register } from "actions/auth"; +// import { register } from "actions/auth"; import { Routes } from "constants/routes"; import { getErrorMessage } from "utils/error"; +import { registerThunk, loginThunk } from "store/user/thunks"; import { model, validationSchema, initialValues, inputs } from "./config"; @@ -37,13 +38,26 @@ export const SignUpPage = () => { const handleFormSubmit = async ({ email, password }) => { try { setIsLoading(true); - await register({ name: email, password })(dispatch); - setIsLoading(false); - history.push({ - pathname: Routes.Auth.ConfirmEmail, - state: { email, isFromRegister: true }, - }); + const { confirmEmail } = await dispatch( + registerThunk({ name: email, password }) + ); + + const pathname = Routes.Auth.ConfirmEmail; + const state = { + email, + password, + variant: "Confirm", + isFromRegister: true, + }; + + if (!confirmEmail) { + await dispatch(loginThunk({ name: email, password })); + state.variant = "Success"; + } + + setIsLoading(false); + history.push({ pathname, state }); } catch (error) { setIsLoading(false); alert.error(getErrorMessage(error)); diff --git a/src/store/user/thunks.js b/src/store/user/thunks.js index cebb1a4d5..aad04c128 100644 --- a/src/store/user/thunks.js +++ b/src/store/user/thunks.js @@ -1,8 +1,73 @@ +import { Auth, Amplify } from "aws-amplify"; + import { api } from "api"; +import { authConfig } from "config/amplify"; import { getErrorMessage } from "utils/error"; import { setCurrentUser } from "./slices"; +const isCognito = process.env.REACT_APP_AUTH_METHOD === "cognito"; + +if (isCognito) { + Amplify.configure({ Auth: { ...authConfig } }); +} + +export const registerThunk = + ({ name, password }) => + async (dispatch) => { + try { + // TODO: Set isLoading; + await api.auth.register({ username: name, password }); + + if (isCognito) { + return Promise.resolve({ confirmEmail: true }); + } + + return Promise.resolve({ confirmEmail: false }); + } catch (error) { + return Promise.reject(error); + } + }; + +export const loginThunk = + ({ name, password }) => + async (dispatch) => { + try { + // TODO: Set isLoading + + let response; + let data = {}; + let username = name; + + if (isCognito) { + if (username.includes("@")) username = username.replace(/@/g, ""); + response = await Auth.signIn(username, password); + + data = { + id: response?.attributes?.sub || "", + token: response?.signInUserSession?.idToken?.jwtToken || "", + }; + + await api.auth.login({ id: data.id }); + } + + if (!isCognito) { + response = await api.auth.login({ username, password }); + data = { ...response.data }; + } + + const user = await api.user.get({ id: data.id }); + const profile = { ...response, ...user?.data?.results }; + + dispatch(setCurrentUser(profile)); + localStorage.setItem("userInfo", JSON.stringify(data)); + + return Promise.resolve(response); + } catch (error) { + return Promise.reject(error); + } + }; + export const getCurrentUserThunk = () => async (dispatch) => { try { const storage = JSON.parse(window.localStorage.getItem("userInfo")); From 3f3d8b23d309e6118406d7049905cd79d6702ee2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 10 May 2022 00:55:49 +0300 Subject: [PATCH 32/34] implement loader provider --- public/index.html | 1 + src/actions/auth.js | 170 +-------------------- src/common/loader/index.jsx | 17 --- src/common/modal/index.jsx | 12 ++ src/common/modal/styles.scss | 33 ++-- src/components/privateRoute/index.jsx | 23 ++- src/index.jsx | 8 +- src/layout/auth/index.jsx | 4 - src/providers/index.js | 2 + src/providers/loader/index.jsx | 31 ++++ src/screens/auth/additional-info/config.js | 8 - src/screens/auth/additional-info/index.jsx | 10 +- src/screens/auth/confirm-email/index.jsx | 35 +++-- src/screens/auth/forgot-password/index.jsx | 37 +++-- src/screens/auth/sign-in/index.jsx | 10 +- src/screens/auth/sign-up/index.jsx | 9 -- src/store/index.js | 2 + src/store/loader/selectors.js | 8 + src/store/loader/slices.js | 20 +++ src/store/user/thunks.js | 103 ++++++++++++- 20 files changed, 251 insertions(+), 292 deletions(-) create mode 100644 src/providers/index.js create mode 100644 src/providers/loader/index.jsx create mode 100644 src/store/loader/selectors.js create mode 100644 src/store/loader/slices.js diff --git a/public/index.html b/public/index.html index 5583c169a..8cd2bf10b 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,7 @@
+
diff --git a/src/actions/auth.js b/src/actions/auth.js index b7a09afa0..d33e61349 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -2,17 +2,7 @@ import Amplify, { Auth } from "aws-amplify"; import { api } from "api"; import { authConfig } from "config/amplify"; -import { getErrorMessage } from "utils/error"; -import { setCurrentUser } from "store/user/slices"; -import { - USER_LOGOUT, - USER_LOGIN_FAIL, - USER_LOGIN_SUCCESS, - ACCESS_TOKEN_SUCCESS, -} from "constants/userConstants"; - -// import { news } from "./community"; -// import { visitCommunity } from "./communityActions"; +import { USER_LOGOUT } from "constants/userConstants"; const isCognito = process.env.REACT_APP_AUTH_METHOD === "cognito"; @@ -20,7 +10,6 @@ if (isCognito) { Amplify.configure({ Auth: { ...authConfig } }); } -// TODO: Move to store/thunk when reduxjs/toolkit will be setuped const makeLogout = (dispatch) => { // dispatch({ type: USER_DETAILS_FAIL, payload: message }); localStorage.clear(); @@ -39,163 +28,6 @@ export const logout = () => async (dispatch) => { } }; -// TODO: Move to store/thunk when reduxjs/toolkit will be setuped -export const getAccessToken = () => async (dispatch) => { - try { - const response = await api.auth.getToken(); - - if (response.status !== 201) { - makeLogout(dispatch); - return Promise.reject(); - } - - dispatch({ type: ACCESS_TOKEN_SUCCESS, payload: true }); - return Promise.resolve(); - } catch (error) { - makeLogout(dispatch); - return Promise.reject(error); - } -}; - -export const login = - ({ name, password }) => - async (dispatch) => { - try { - // TODO: Set isLoading - - let response; - let data = {}; - let username = name; - - if (isCognito) { - if (username.includes("@")) username = username.replace(/@/g, ""); - response = await Auth.signIn(username, password); - - data = { - id: response?.attributes?.sub || "", - token: response?.signInUserSession?.idToken?.jwtToken || "", - }; - - await api.auth.login({ id: data.id }); - } - - if (!isCognito) { - response = await api.auth.login({ username, password }); - data = { ...response.data }; - } - - localStorage.setItem("userInfo", JSON.stringify(data)); - - const user = await api.user.get({ id: data.id }); - - dispatch({ type: USER_LOGIN_SUCCESS, payload: response }); - dispatch(setCurrentUser({ ...response, ...user?.data?.results })); - - return Promise.resolve(response); - } catch (error) { - return Promise.reject(error); - } - }; - -// TODO: Move to store/thunk when reduxjs/toolkit will be setuped -// export const login = -// ({ name, password }) => -// async (dispatch) => { -// try { -// let authData = {}; -// let response; -// if (isCognito) { -// try { -// // try to use formatted username -// const username = name.replace(/@/g, ""); -// response = await Auth.signIn(username, password); -// } catch (error) { -// console.log(error); -// if (error.code === "UserNotConfirmedException") { -// // TODO: add handling unconfirmed user -// throw error; -// } -// // try non formatted email -// response = await Auth.signIn(name, password); -// } - -// const id = response?.attributes?.sub || ""; -// authData = { -// id, -// token: response?.signInUserSession?.idToken?.jwtToken || "", -// }; -// await api.auth.login({ id }); -// } else { -// response = await api.auth.login({ username: name, password }); -// authData = response.data; -// } - -// localStorage.setItem("userInfo", JSON.stringify(authData)); - -// const profile = await api.user.get({ id: authData.id }); -// dispatch(setCurrentUser({ ...response, ...profile?.data?.results })); - -// // await getAccessToken()(dispatch); // access token is already received in login response -// // const community = await news()(dispatch); -// // await visitCommunity(community.id)(dispatch); - -// dispatch({ type: USER_LOGIN_SUCCESS, payload: response }); -// return Promise.resolve(response); -// } catch (error) { -// dispatch({ type: USER_LOGIN_FAIL, payload: getErrorMessage(error) }); -// return Promise.reject(error); -// } -// }; - -export const register = - ({ name, password }) => - async (dispatch) => { - try { - const response = await api.auth.register({ username: name, password }); - console.log("register", response); - - // no auto login for cognito since it needs to confirm email with a code - if (!isCognito) { - await login({ name, password })(dispatch); - } else if (response.data.forSignUpConfirmation) { - // TODO: add handling for sign up confirmation - } - - return Promise.resolve(); - } catch (error) { - console.error("register", error); - return Promise.reject(error); - } - }; - -// TODO: Why there is no functionality to request code without cognito? -export const requestCode = async (username) => { - try { - let response; - if (isCognito) { - const data = await api.auth.forgotPassword(username); - response = - data.data.details.CodeDeliveryDetails.AttributeName.split("_").join( - " " - ); - } - return Promise.resolve(response); - } catch (error) { - return Promise.reject(error); - } -}; - -export const resetPassword = async ({ username, code, password }) => { - try { - if (isCognito) { - await api.auth.forgotPasswordSubmit(username, code, password); - } - return Promise.resolve(); - } catch (error) { - return Promise.reject(error); - } -}; - export const changePassword = async ({ oldPassword, newPassword }) => { try { if (isCognito) { diff --git a/src/common/loader/index.jsx b/src/common/loader/index.jsx index 9984c952d..95e1614bb 100644 --- a/src/common/loader/index.jsx +++ b/src/common/loader/index.jsx @@ -13,23 +13,6 @@ const options = { }, }; -// TODO: Fix underlay scrolling of content when loader is showed; -// TODO: Show/Hide loader from redux, when reduxjs/toolkit will be setuped; - -export const Loader = ({ maxHeight = "800px" }) => { - return ( -
-
- -
-
- ); -}; - export const ComponentLoader = ({ width = "100%", height = "100%" }) => { return (
diff --git a/src/common/modal/index.jsx b/src/common/modal/index.jsx index 780545db2..4c35815fe 100644 --- a/src/common/modal/index.jsx +++ b/src/common/modal/index.jsx @@ -17,6 +17,18 @@ export const Modal = ({ visible, children, modalRef }) => { ); }; +export const LoaderModal = ({ visible, children, modalRef }) => { + if (!visible) return null; + + return ( + +
+ {children} +
+
+ ); +}; + export const CommonModal = ({ visible, title, onClose, children }) => { if (!visible) return null; diff --git a/src/common/modal/styles.scss b/src/common/modal/styles.scss index 6d88eb0b3..c93de3735 100644 --- a/src/common/modal/styles.scss +++ b/src/common/modal/styles.scss @@ -1,18 +1,11 @@ @import "src/scss/mixins"; .portal-modal-container { + @include position(fixed, 0, 0, 0, 0); + @include flexColumn(center, center); + backdrop-filter: blur(4px); background-color: rgba(0, 0, 0, 0.3); - - top: 0; - left: 0; - right: 0; - bottom: 0; - position: fixed; - - display: flex; - align-items: center; - justify-content: center; } .common-modal-container { @@ -22,17 +15,13 @@ border-radius: 4px; background-color: #191b1d; - display: flex; - flex-direction: column; + @include flexColumn(auto, auto); .top-container { width: 100%; height: 56px; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; margin-bottom: 24px; + @include flexRow(center, space-between); } @include breakpoint("mobile") { @@ -41,3 +30,15 @@ max-width: unset; } } + +.app-portal-loader-container { + width: 100%; + height: 100%; + + z-index: 1000; + @include flexColumn(center, center); + @include position(fixed, 0, 0, 0, 0); + + backdrop-filter: blur(8px); + background-color: rgba(0, 0, 0, 0.5); +} diff --git a/src/components/privateRoute/index.jsx b/src/components/privateRoute/index.jsx index d8aeb90c5..debf3105b 100644 --- a/src/components/privateRoute/index.jsx +++ b/src/components/privateRoute/index.jsx @@ -3,42 +3,43 @@ import { useAlert } from "react-alert"; import { Route, Redirect } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; -import { Loader } from "common/loader"; - +import { setIsLoading } from "store/loader/slices"; import { getCurrentUserThunk } from "store/user/thunks"; import { selectCurrentUser } from "store/user/selectors"; +import { selectIsLoading } from "store/loader/selectors"; // import { checkAndUpdateToken } from "../../actions/userAction"; export const PrivateRoute = ({ component: Component, ...rest }) => { const alert = useAlert(); const dispatch = useDispatch(); - const currentUser = useSelector(selectCurrentUser); const [isAuthed, setIsAuthed] = useState(false); - const [isLoading, setIsLoading] = useState(true); + + const isLoading = useSelector(selectIsLoading); + const currentUser = useSelector(selectCurrentUser); useEffect(() => { if (!currentUser) { setIsAuthed(false); - setIsLoading(true); + dispatch(setIsLoading(true)); } if (currentUser) { setIsAuthed(true); - setIsLoading(false); + dispatch(setIsLoading(false)); } }, [currentUser]); useEffect(async () => { if (!isAuthed && isLoading) { try { - const response = await getCurrentUserThunk()(dispatch); + const response = await dispatch(getCurrentUserThunk()); setIsAuthed(response.isAuthed); - setIsLoading(false); } catch (error) { if (error) alert.error(error); setIsAuthed(false); - setIsLoading(false); + } finally { + dispatch(setIsLoading(false)); } } }, [isAuthed, isLoading]); @@ -49,10 +50,6 @@ export const PrivateRoute = ({ component: Component, ...rest }) => { // return userInfo && dispatch(checkAndUpdateToken()); // }; - if (isLoading) { - return ; - } - return ( - + + + , document.getElementById("app") diff --git a/src/layout/auth/index.jsx b/src/layout/auth/index.jsx index ed31d1344..3345def0e 100644 --- a/src/layout/auth/index.jsx +++ b/src/layout/auth/index.jsx @@ -1,7 +1,6 @@ import { Formik, Form } from "formik"; import { Icon } from "common/icon"; -import { Loader } from "common/loader"; import { BlurContainer } from "layout/blur-container"; import "./styles.scss"; @@ -14,7 +13,6 @@ export const AuthLayout = ({ initialValues, withLogo = true, validationSchema, - isLoading = false, enableReinitialize = false, }) => { return ( @@ -57,8 +55,6 @@ export const AuthLayout = ({
)}
- - {isLoading && } ); }; diff --git a/src/providers/index.js b/src/providers/index.js new file mode 100644 index 000000000..88bd70e13 --- /dev/null +++ b/src/providers/index.js @@ -0,0 +1,2 @@ +export { LoaderProvider } from "./loader"; +export { SearchBarProvider, SearchBarContext } from "./search-bar"; diff --git a/src/providers/loader/index.jsx b/src/providers/loader/index.jsx new file mode 100644 index 000000000..c2005d5d7 --- /dev/null +++ b/src/providers/loader/index.jsx @@ -0,0 +1,31 @@ +import { useSelector } from "react-redux"; +import Lottie from "lottie-react"; + +import { LoaderModal } from "common/modal"; +import { selectIsLoading } from "store/loader/selectors"; +import loaderAnimation from "assets/animations/loader.json"; + +const options = { + loop: true, + autoplay: true, + rendererSettings: { + preserveAspectRatio: "xMidYMid slice", + }, +}; + +export const LoaderProvider = ({ children }) => { + const isLoading = useSelector(selectIsLoading); + + return ( + <> + + + + {children} + + ); +}; diff --git a/src/screens/auth/additional-info/config.js b/src/screens/auth/additional-info/config.js index 52a4c99ab..04977d6fe 100644 --- a/src/screens/auth/additional-info/config.js +++ b/src/screens/auth/additional-info/config.js @@ -25,11 +25,6 @@ export const model = { type: "text", placeholder: "Last Name", }, - email: { - name: "email", - type: "email", - placeholder: "Email", - }, phoneNumber: { name: "phoneNumber", type: "tel", @@ -49,7 +44,6 @@ export const model = { export const inputs = [ model.firstName, model.lastName, - model.email, model.phoneNumber, model.birthdate, ]; @@ -60,7 +54,6 @@ const infoValidation = Yup.object().shape({ [model.phoneNumber.name]: Yup.string() .optional() .matches(phoneRegex, "Invalid Phone Number"), - [model.email.name]: Yup.string().email().optional(), [model.birthdate.name]: Yup.string() .optional() .matches(dateRegex, "Invalid Date") @@ -79,7 +72,6 @@ const infoInitialValues = { [model.firstName.name]: "", [model.lastName.name]: "", [model.phoneNumber.name]: "", - [model.email.name]: "", [model.birthdate.name]: "", }; diff --git a/src/screens/auth/additional-info/index.jsx b/src/screens/auth/additional-info/index.jsx index c2ae97c70..17158e167 100644 --- a/src/screens/auth/additional-info/index.jsx +++ b/src/screens/auth/additional-info/index.jsx @@ -9,6 +9,7 @@ import { ActionButton } from "common/buttons/action-button"; import { InputsContainer, ButtonsContainer } from "components/auth"; import { getErrorMessage } from "utils/error"; +import { setIsLoading } from "store/loader/slices"; import { updateUserInfo } from "actions/userAction"; import { configurePayload } from "./helpers"; @@ -26,26 +27,24 @@ export const AdditionalInfoPage = () => { const history = useHistory(); const dispatch = useDispatch(); - const [isLoading, setIsLoading] = useState(false); const [step, setStep] = useState(AdditionalStep.Info); const onSubmit = async (values) => { try { - setIsLoading(true); + dispatch(setIsLoading(true)); const payload = configurePayload(values); await updateUserInfo(payload)(dispatch); if (step === AdditionalStep.Info) { setStep(AdditionalStep.Avatar); - setIsLoading(false); } else { - setIsLoading(false); history.replace("/news"); } } catch (error) { - setIsLoading(false); alert.error(getErrorMessage(error)); + } finally { + dispatch(setIsLoading(false)); } }; @@ -63,7 +62,6 @@ export const AdditionalInfoPage = () => { { const history = useHistory(); const dispatch = useDispatch(); - // const [email, setEmail] = useState(""); - const [isLoading, setIsLoading] = useState(false); const [variant, setVariant] = useState(Variant.Confirm); const [isFromRegister, setIsFromRegister] = useState(true); const [data, setData] = useState({ email: "", password: "" }); @@ -53,20 +53,19 @@ export const ConfirmEmailPage = () => { const handleSubmit = useCallback( async ({ code }) => { try { - setIsLoading(true); - - await api.auth.confirmEmail({ email: data.email, code }); await dispatch( - loginThunk({ - name: data.email, + confirmEmailThunk({ + code, + email: data.email, password: data.password, }) ); - if (isFromRegister) setVariant(Variant.Success); - else history.push(Routes.News.Home); - - setIsLoading(false); + if (isFromRegister) { + setVariant(Variant.Success); + } else { + history.push(Routes.News.Home); + } } catch (error) { alert.error(getErrorMessage(error)); } @@ -76,19 +75,19 @@ export const ConfirmEmailPage = () => { const handleResendClick = useCallback(async () => { try { - setIsLoading(true); - await api.auth.resendEmailCode({ email: data.email }); + await dispatch( + requestConfirmEmailCodeThunk({ + email: data.email, + }) + ); alert.success(`We've sent new confirmation code at ${data.email}`); } catch (error) { alert.error(getErrorMessage(error)); - } finally { - setIsLoading(false); } }, [data.email]); return ( { const alert = useAlert(); const history = useHistory(); - - const [isLoading, setIsLoading] = useState(false); + const dispatch = useDispatch(); const handleFormSubmit = async (values, actions) => { const { codeRequested, username, code, password } = values; @@ -36,21 +34,22 @@ export const ForgotPasswordPage = () => { } try { - setIsLoading(true); - if (!codeRequested) { - const response = await requestCode(username); + const response = await dispatch(requestCodeThunk(username)); actions.setFieldValue(model.codeRequested.name, true); - if (response) alert.success(`Code has been sent to ${response}!`); - else alert.success("Code has been sent!"); - setIsLoading(false); + + alert.success( + response + ? `Code has been sent to ${response}!` + : "Code has been sent!" + ); } else { - await resetPassword({ username, code, password }); + await dispatch(resetPasswordThunk({ username, code, password })); + alert.success("Password has been successfully changed!"); history.push("/login"); } } catch (error) { - setIsLoading(false); alert.error(getErrorMessage(error)); } }; @@ -67,14 +66,13 @@ export const ForgotPasswordPage = () => { } try { - setIsLoading(true); - const response = await requestCode(values.username); - if (response) alert.success(`Code has been sent to ${response}!`); - else alert.success("Code has been sent!"); + const response = await dispatch(requestCodeThunk(values.username)); + + alert.success( + response ? `Code has been sent to ${response}!` : "Code has been sent!" + ); } catch (error) { alert.error(getErrorMessage(error)); - } finally { - setIsLoading(false); } }; @@ -87,7 +85,6 @@ export const ForgotPasswordPage = () => { return ( { const dispatch = useDispatch(); const [remember, setRemember] = useState(false); - const [isLoading, setIsLoading] = useState(false); const onGoogleLogin = () => { // Auth.federatedSignIn({ provider: "Google" }); @@ -42,12 +40,9 @@ export const SignInPage = () => { const handleFormSubmit = async ({ username, password }) => { try { - setIsLoading(true); await dispatch(loginThunk({ name: username, password })); history.push(Routes.News.Home); - setIsLoading(false); } catch (error) { - setIsLoading(false); alert.error(getErrorMessage(error)); if (isNonConfirmedError(error)) { @@ -63,7 +58,6 @@ export const SignInPage = () => { return ( { const history = useHistory(); const dispatch = useDispatch(); - const [isLoading, setIsLoading] = useState(false); - const onGoogleLogin = () => { // Auth.federatedSignIn({ provider: "Google" }); }; @@ -37,8 +33,6 @@ export const SignUpPage = () => { const handleFormSubmit = async ({ email, password }) => { try { - setIsLoading(true); - const { confirmEmail } = await dispatch( registerThunk({ name: email, password }) ); @@ -56,10 +50,8 @@ export const SignUpPage = () => { state.variant = "Success"; } - setIsLoading(false); history.push({ pathname, state }); } catch (error) { - setIsLoading(false); alert.error(getErrorMessage(error)); } }; @@ -67,7 +59,6 @@ export const SignUpPage = () => { return ( store.loader; + +export const selectIsLoading = createSelector( + [loader], + (store) => store.isLoading +); diff --git a/src/store/loader/slices.js b/src/store/loader/slices.js new file mode 100644 index 000000000..dd11c6785 --- /dev/null +++ b/src/store/loader/slices.js @@ -0,0 +1,20 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const initialState = { isLoading: false }; + +const slice = createSlice({ + name: "loader", + initialState, + reducers: { + setIsLoading: (state, { payload }) => { + state.isLoading = payload; + }, + }, +}); + +const { + reducer: loaderReducer, + actions: { setIsLoading }, +} = slice; + +export { loaderReducer, setIsLoading }; diff --git a/src/store/user/thunks.js b/src/store/user/thunks.js index aad04c128..2b41d9a69 100644 --- a/src/store/user/thunks.js +++ b/src/store/user/thunks.js @@ -3,6 +3,7 @@ import { Auth, Amplify } from "aws-amplify"; import { api } from "api"; import { authConfig } from "config/amplify"; import { getErrorMessage } from "utils/error"; +import { setIsLoading } from "store/loader/slices"; import { setCurrentUser } from "./slices"; @@ -16,7 +17,7 @@ export const registerThunk = ({ name, password }) => async (dispatch) => { try { - // TODO: Set isLoading; + dispatch(setIsLoading(true)); await api.auth.register({ username: name, password }); if (isCognito) { @@ -26,6 +27,8 @@ export const registerThunk = return Promise.resolve({ confirmEmail: false }); } catch (error) { return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); } }; @@ -33,7 +36,7 @@ export const loginThunk = ({ name, password }) => async (dispatch) => { try { - // TODO: Set isLoading + dispatch(setIsLoading(true)); let response; let data = {}; @@ -65,11 +68,14 @@ export const loginThunk = return Promise.resolve(response); } catch (error) { return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); } }; export const getCurrentUserThunk = () => async (dispatch) => { try { + dispatch(setIsLoading(true)); const storage = JSON.parse(window.localStorage.getItem("userInfo")); if (!storage || !storage.id) { @@ -82,5 +88,98 @@ export const getCurrentUserThunk = () => async (dispatch) => { return Promise.resolve({ isAuthed: true }); } catch (error) { return Promise.reject(getErrorMessage(error)); + } finally { + dispatch(setIsLoading(false)); } }; + +// TODO: Why there is no functionality to request code without cognito? +export const requestCodeThunk = (username) => async (dispatch) => { + try { + dispatch(setIsLoading(true)); + + let response; + if (isCognito) { + const data = await api.auth.forgotPassword(username); + response = + data.data.details.CodeDeliveryDetails.AttributeName.split("_").join( + " " + ); + } + return Promise.resolve(response); + } catch (error) { + return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); + } +}; + +export const resetPasswordThunk = + ({ username, code, password }) => + async (dispatch) => { + try { + dispatch(setIsLoading(true)); + if (isCognito) { + await api.auth.forgotPasswordSubmit(username, code, password); + } + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); + } + }; + +export const changePasswordThunk = + ({ oldPassword, newPassword }) => + async (dispatch) => { + try { + dispatch(setIsLoading(true)); + + if (isCognito) { + const user = await Auth.currentAuthenticatedUser(); + await Auth.changePassword(user, oldPassword, newPassword); + } else { + await api.auth.changePassword({ oldPassword, newPassword }); + } + + return Promise.resolve(); + } catch (error) { + // TODO: From backend receive wrong error object; + // TODO: Backend_Bug: Always incorrect password error, but password has been changed; + return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); + } + }; + +export const confirmEmailThunk = + ({ code, email, password }) => + async (dispatch) => { + try { + dispatch(setIsLoading(true)); + + await api.auth.confirmEmail({ email, code }); + await dispatch(loginThunk({ name: email, password })); + + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); + } + }; + +export const requestConfirmEmailCodeThunk = + ({ email }) => + async (dispatch) => { + try { + dispatch(setIsLoading(true)); + await api.auth.resendEmailCode({ email }); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } finally { + dispatch(setIsLoading(false)); + } + }; From da47c95112750ee327494925c5c86b0fc17ea376 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 11 May 2022 01:00:30 +0300 Subject: [PATCH 33/34] fix login --- src/components/privateRoute/index.jsx | 67 +++++++++++---------------- src/store/user/selectors.js | 5 ++ src/store/user/slices.js | 9 ++-- src/store/user/thunks.js | 33 ++++++------- 4 files changed, 56 insertions(+), 58 deletions(-) diff --git a/src/components/privateRoute/index.jsx b/src/components/privateRoute/index.jsx index debf3105b..9fa42136b 100644 --- a/src/components/privateRoute/index.jsx +++ b/src/components/privateRoute/index.jsx @@ -1,60 +1,49 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useAlert } from "react-alert"; import { Route, Redirect } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; -import { setIsLoading } from "store/loader/slices"; -import { getCurrentUserThunk } from "store/user/thunks"; -import { selectCurrentUser } from "store/user/selectors"; -import { selectIsLoading } from "store/loader/selectors"; +import { Routes } from "constants/routes"; +import { useStateIfMounted } from "hooks"; -// import { checkAndUpdateToken } from "../../actions/userAction"; +import { selectIsAuthed } from "store/user/selectors"; +import { getCurrentUserThunk } from "store/user/thunks"; -export const PrivateRoute = ({ component: Component, ...rest }) => { +const CheckAuthRoute = ({ isAuthed }) => { const alert = useAlert(); const dispatch = useDispatch(); - const [isAuthed, setIsAuthed] = useState(false); - - const isLoading = useSelector(selectIsLoading); - const currentUser = useSelector(selectCurrentUser); - - useEffect(() => { - if (!currentUser) { - setIsAuthed(false); - dispatch(setIsLoading(true)); - } - if (currentUser) { - setIsAuthed(true); - dispatch(setIsLoading(false)); - } - }, [currentUser]); + const [isRequesting, setIsRequesting] = useStateIfMounted(true); useEffect(async () => { - if (!isAuthed && isLoading) { - try { - const response = await dispatch(getCurrentUserThunk()); - setIsAuthed(response.isAuthed); - } catch (error) { - if (error) alert.error(error); - setIsAuthed(false); - } finally { - dispatch(setIsLoading(false)); - } + try { + await dispatch(getCurrentUserThunk()); + } catch (error) { + if (error) alert.error(error); + } finally { + setIsRequesting(false); } - }, [isAuthed, isLoading]); + }, []); + + if (!isAuthed && isRequesting) return <>; + + if (!isAuthed && !isRequesting) return ; - // const hasAccess = () => { - // const userInfo = window.localStorage.getItem("userInfo"); - // console.log(userInfo); - // return userInfo && dispatch(checkAndUpdateToken()); - // }; + return null; +}; + +export const PrivateRoute = ({ component: Component, ...rest }) => { + const isAuthed = useSelector(selectIsAuthed); return ( - isAuthed ? : + isAuthed ? ( + + ) : ( + + ) } /> ); diff --git a/src/store/user/selectors.js b/src/store/user/selectors.js index cd7444bd1..dbae35e69 100644 --- a/src/store/user/selectors.js +++ b/src/store/user/selectors.js @@ -6,3 +6,8 @@ export const selectCurrentUser = createSelector( [selectUserStore], (store) => store.currentProfile ); + +export const selectIsAuthed = createSelector( + [selectUserStore], + (store) => store.isAuthed +); diff --git a/src/store/user/slices.js b/src/store/user/slices.js index edb08e4da..5c247ea55 100644 --- a/src/store/user/slices.js +++ b/src/store/user/slices.js @@ -1,6 +1,6 @@ import { createSlice } from "@reduxjs/toolkit"; -const initialState = { currentProfile: null }; +const initialState = { isAuthed: false, currentProfile: null }; const userSlice = createSlice({ name: "user", @@ -9,12 +9,15 @@ const userSlice = createSlice({ setCurrentUser: (state, { payload }) => { state.currentProfile = { ...payload }; }, + setIsAuthed: (state, { payload }) => { + state.isAuthed = payload; + }, }, }); const { reducer: userReducer, - actions: { setCurrentUser }, + actions: { setCurrentUser, setIsAuthed }, } = userSlice; -export { userReducer, setCurrentUser }; +export { userReducer, setCurrentUser, setIsAuthed }; diff --git a/src/store/user/thunks.js b/src/store/user/thunks.js index 2b41d9a69..9f44aab8e 100644 --- a/src/store/user/thunks.js +++ b/src/store/user/thunks.js @@ -2,10 +2,9 @@ import { Auth, Amplify } from "aws-amplify"; import { api } from "api"; import { authConfig } from "config/amplify"; -import { getErrorMessage } from "utils/error"; import { setIsLoading } from "store/loader/slices"; -import { setCurrentUser } from "./slices"; +import { setCurrentUser, setIsAuthed } from "./slices"; const isCognito = process.env.REACT_APP_AUTH_METHOD === "cognito"; @@ -38,34 +37,33 @@ export const loginThunk = try { dispatch(setIsLoading(true)); - let response; - let data = {}; + let data; let username = name; if (isCognito) { if (username.includes("@")) username = username.replace(/@/g, ""); - response = await Auth.signIn(username, password); + const response = await Auth.signIn(username, password); data = { id: response?.attributes?.sub || "", token: response?.signInUserSession?.idToken?.jwtToken || "", }; - - await api.auth.login({ id: data.id }); - } - - if (!isCognito) { - response = await api.auth.login({ username, password }); + } else { + const response = await api.auth.login({ username, password }); data = { ...response.data }; } + localStorage.setItem("userInfo", JSON.stringify(data)); + const authData = isCognito ? { id: data.id } : { username, password }; + + await api.auth.login(authData); const user = await api.user.get({ id: data.id }); - const profile = { ...response, ...user?.data?.results }; + const profile = { ...user?.data?.results }; + dispatch(setIsAuthed(true)); dispatch(setCurrentUser(profile)); - localStorage.setItem("userInfo", JSON.stringify(data)); - return Promise.resolve(response); + return Promise.resolve(); } catch (error) { return Promise.reject(error); } finally { @@ -83,11 +81,14 @@ export const getCurrentUserThunk = () => async (dispatch) => { } const response = await api.user.get({ id: storage.id }); + + dispatch(setIsAuthed(true)); dispatch(setCurrentUser({ ...response?.data?.results })); - return Promise.resolve({ isAuthed: true }); + return Promise.resolve(); } catch (error) { - return Promise.reject(getErrorMessage(error)); + dispatch(setIsAuthed(false)); + return Promise.reject(error); } finally { dispatch(setIsLoading(false)); } From 0b0018126f7e3a8b7fc1be2d12f7c2ef66aa2c27 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 12 May 2022 10:46:31 +0300 Subject: [PATCH 34/34] minor fix --- src/routes/index.jsx | 10 +++++----- src/screens/courses/course/main-info/index.jsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/index.jsx b/src/routes/index.jsx index e56470364..65f5d8c0b 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -120,6 +120,11 @@ export const Routes = () => { component={CoursesListPage} path={PathRoutes.Courses.Home} /> + { component={MembersPage} path={PathRoutes.Courses.Members} /> - {/* */} diff --git a/src/screens/courses/course/main-info/index.jsx b/src/screens/courses/course/main-info/index.jsx index 276b7d5f9..535f9eb98 100644 --- a/src/screens/courses/course/main-info/index.jsx +++ b/src/screens/courses/course/main-info/index.jsx @@ -27,7 +27,7 @@ export const CourseMainInfo = ({ ? `$${parseFloat(parseFloat(price) / 100).toFixed(2)}` : "$00.00"; - const courseMembers = `${members.length || 0} people tried`; + const courseMembers = `${members?.length || 0} people tried`; const handleMoreOptionClick = (option) => { if (option === MoreOption.Review && onAddReview) onAddReview();