From 3f1088c4794a767623f6590b1c349508832a5281 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sat, 25 Mar 2023 00:43:48 +0200 Subject: [PATCH 001/119] UOF import challenges --- src-ts/config/constants.ts | 1 + src-ts/tools/earn-app/EarnApp.tsx | 27 + src-ts/tools/earn-app/README.md | 3 + src-ts/tools/earn-app/earn.routes.tsx | 32 + src-ts/tools/earn-app/index.ts | 4 + src-ts/tools/tools.routes.ts | 2 + src/App.jsx | 1 - src/assets/icons/banner-chevron-up.svg | 17 + src/assets/icons/find-work-green.svg | 16 + src/assets/icons/find-work.svg | 12 + src/assets/icons/menu-chevron-up.svg | 17 + src/assets/icons/my-work-green.svg | 11 + src/assets/icons/my-work.svg | 15 + .../actions/challenge-listing/filter-panel.js | 23 + src/earn/actions/challenge-listing/index.js | 54 + src/earn/actions/challenge-listing/sidebar.js | 158 +++ src/earn/actions/challenge.js | 576 ++++++++++ src/earn/actions/challenges.js | 92 ++ src/earn/actions/filter.js | 22 + src/earn/actions/index.js | 7 + src/earn/components/Banner/index.jsx | 47 + src/earn/components/Banner/styles.scss | 65 ++ src/earn/components/Menu/index.jsx | 180 ++++ src/earn/components/Menu/styles.scss | 102 ++ src/earn/components/Panel/index.jsx | 40 + src/earn/components/Panel/styles.scss | 41 + src/earn/constants/challenges.js | 104 ++ src/earn/constants/index.js | 2 + src/earn/constants/menu.js | 33 + .../ChallengeItem/NumRegistrants/index.jsx | 17 + .../ChallengeItem/NumRegistrants/styles.scss | 15 + .../ChallengeItem/NumSubmissions/index.jsx | 17 + .../ChallengeItem/NumSubmissions/styles.scss | 15 + .../ChallengeItem/PhaseEndDate/index.jsx | 60 ++ .../ChallengeItem/PhaseEndDate/styles.scss | 23 + .../Listing/ChallengeItem/Prize/index.jsx | 21 + .../Listing/ChallengeItem/Prize/styles.scss | 34 + .../Listing/ChallengeItem/Tags/index.jsx | 70 ++ .../Listing/ChallengeItem/Tags/styles.scss | 27 + .../Listing/ChallengeItem/TrackIcon/index.jsx | 27 + .../ChallengeItem/TrackIcon/styles.scss | 18 + .../Listing/ChallengeItem/index.jsx | 109 ++ .../Listing/ChallengeItem/styles.scss | 138 +++ .../Listing/errors/ChallengeError/index.jsx | 17 + .../Listing/errors/ChallengeError/styles.scss | 20 + .../ChallengeRecommendedError/index.jsx | 18 + .../ChallengeRecommendedError/styles.scss | 24 + .../containers/Challenges/Listing/index.jsx | 219 ++++ .../containers/Challenges/Listing/styles.scss | 133 +++ .../PlacementsTooltip/Prize/index.jsx | 29 + .../PlacementsTooltip/Prize/styles.scss | 52 + .../tooltips/PlacementsTooltip/index.jsx | 57 + .../tooltips/PlacementsTooltip/styles.scss | 23 + .../tooltips/ProgressTooltip/index.jsx | 142 +++ .../tooltips/ProgressTooltip/styles.scss | 89 ++ .../tooltips/TagsMoreTooltip/index.jsx | 36 + .../tooltips/TagsMoreTooltip/styles.scss | 17 + src/earn/containers/Challenges/index.jsx | 221 ++++ src/earn/containers/Challenges/styles.scss | 87 ++ .../Filter/ChallengeFilter/index.jsx | 337 ++++++ .../Filter/ChallengeFilter/styles.scss | 144 +++ src/earn/containers/Filter/index.jsx | 134 +++ src/earn/containers/Menu/index.jsx | 51 + .../challenge-listing/filter-panel.js | 43 + src/earn/reducers/challenge-listing/index.js | 434 ++++++++ .../reducers/challenge-listing/sidebar.js | 225 ++++ src/earn/reducers/challenges.js | 64 ++ src/earn/reducers/filter.js | 59 ++ src/earn/reducers/index.js | 10 + src/earn/routes/ChallengeList/index.jsx | 117 +++ src/earn/services/challenge-api.js | 255 +++++ src/earn/services/challenge.js | 117 +++ src/earn/services/challenges.js | 984 ++++++++++++++++++ src/earn/services/members.js | 442 ++++++++ src/earn/services/submissions.js | 130 +++ src/earn/utils/auth.js | 24 + src/earn/utils/challenge-detail/sort.js | 51 + src/earn/utils/challenge-listing/buckets.js | 284 +++++ src/earn/utils/challenge-listing/helper.js | 47 + src/earn/utils/challenge-listing/sort.js | 102 ++ src/earn/utils/challenge.js | 545 ++++++++++ src/earn/utils/errors.js | 66 ++ src/earn/utils/hooks/index.js | 1 + src/earn/utils/hooks/useClickOutside.js | 36 + src/earn/utils/hooks/useCssVariable.js | 13 + src/earn/utils/hooks/usePreviousLocation.js | 13 + src/earn/utils/hooks/useTargetSize.js | 33 + src/earn/utils/icon.js | 17 + src/earn/utils/index.js | 4 + src/earn/utils/logger.js | 103 ++ src/earn/utils/menu.js | 180 ++++ src/earn/utils/redux.js | 34 + src/earn/utils/tc.js | 394 +++++++ src/earn/utils/token.js | 92 ++ src/earn/utils/url.js | 82 ++ src/index.jsx | 50 +- src/styles/_mixins.scss | 4 +- src/styles/_variables.scss | 2 + src/styles/mixins/_media.scss | 99 ++ src/styles/mixins/_typography.scss | 109 ++ 100 files changed, 9382 insertions(+), 28 deletions(-) create mode 100644 src-ts/tools/earn-app/EarnApp.tsx create mode 100644 src-ts/tools/earn-app/README.md create mode 100644 src-ts/tools/earn-app/earn.routes.tsx create mode 100644 src-ts/tools/earn-app/index.ts create mode 100644 src/assets/icons/banner-chevron-up.svg create mode 100644 src/assets/icons/find-work-green.svg create mode 100644 src/assets/icons/find-work.svg create mode 100644 src/assets/icons/menu-chevron-up.svg create mode 100644 src/assets/icons/my-work-green.svg create mode 100644 src/assets/icons/my-work.svg create mode 100644 src/earn/actions/challenge-listing/filter-panel.js create mode 100644 src/earn/actions/challenge-listing/index.js create mode 100644 src/earn/actions/challenge-listing/sidebar.js create mode 100644 src/earn/actions/challenge.js create mode 100644 src/earn/actions/challenges.js create mode 100644 src/earn/actions/filter.js create mode 100644 src/earn/actions/index.js create mode 100644 src/earn/components/Banner/index.jsx create mode 100644 src/earn/components/Banner/styles.scss create mode 100644 src/earn/components/Menu/index.jsx create mode 100644 src/earn/components/Menu/styles.scss create mode 100644 src/earn/components/Panel/index.jsx create mode 100644 src/earn/components/Panel/styles.scss create mode 100644 src/earn/constants/challenges.js create mode 100644 src/earn/constants/index.js create mode 100644 src/earn/constants/menu.js create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/Prize/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/Tags/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/ChallengeItem/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/errors/ChallengeError/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/errors/ChallengeError/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/styles.scss create mode 100644 src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/index.jsx create mode 100644 src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/styles.scss create mode 100644 src/earn/containers/Challenges/index.jsx create mode 100644 src/earn/containers/Challenges/styles.scss create mode 100644 src/earn/containers/Filter/ChallengeFilter/index.jsx create mode 100644 src/earn/containers/Filter/ChallengeFilter/styles.scss create mode 100644 src/earn/containers/Filter/index.jsx create mode 100644 src/earn/containers/Menu/index.jsx create mode 100644 src/earn/reducers/challenge-listing/filter-panel.js create mode 100644 src/earn/reducers/challenge-listing/index.js create mode 100644 src/earn/reducers/challenge-listing/sidebar.js create mode 100644 src/earn/reducers/challenges.js create mode 100644 src/earn/reducers/filter.js create mode 100644 src/earn/reducers/index.js create mode 100644 src/earn/routes/ChallengeList/index.jsx create mode 100644 src/earn/services/challenge-api.js create mode 100644 src/earn/services/challenge.js create mode 100644 src/earn/services/challenges.js create mode 100644 src/earn/services/members.js create mode 100644 src/earn/services/submissions.js create mode 100644 src/earn/utils/auth.js create mode 100644 src/earn/utils/challenge-detail/sort.js create mode 100644 src/earn/utils/challenge-listing/buckets.js create mode 100644 src/earn/utils/challenge-listing/helper.js create mode 100644 src/earn/utils/challenge-listing/sort.js create mode 100644 src/earn/utils/challenge.js create mode 100644 src/earn/utils/errors.js create mode 100644 src/earn/utils/hooks/index.js create mode 100644 src/earn/utils/hooks/useClickOutside.js create mode 100644 src/earn/utils/hooks/useCssVariable.js create mode 100644 src/earn/utils/hooks/usePreviousLocation.js create mode 100644 src/earn/utils/hooks/useTargetSize.js create mode 100644 src/earn/utils/icon.js create mode 100644 src/earn/utils/index.js create mode 100644 src/earn/utils/logger.js create mode 100644 src/earn/utils/menu.js create mode 100644 src/earn/utils/redux.js create mode 100644 src/earn/utils/tc.js create mode 100644 src/earn/utils/token.js create mode 100644 src/earn/utils/url.js create mode 100644 src/styles/mixins/_media.scss create mode 100644 src/styles/mixins/_typography.scss diff --git a/src-ts/config/constants.ts b/src-ts/config/constants.ts index ff7bdc805..2a4c68837 100644 --- a/src-ts/config/constants.ts +++ b/src-ts/config/constants.ts @@ -4,6 +4,7 @@ export enum ToolTitle { settings = 'Account Settings', support = 'Support', tca = 'Topcoder Academy', + earn = 'Opportunity Feed', work = 'Self Service Challenges', } diff --git a/src-ts/tools/earn-app/EarnApp.tsx b/src-ts/tools/earn-app/EarnApp.tsx new file mode 100644 index 000000000..78eddbc4e --- /dev/null +++ b/src-ts/tools/earn-app/EarnApp.tsx @@ -0,0 +1,27 @@ +import { FC, useContext } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { ToolTitle } from '../../config' +import { + ContentLayout, + routeContext, + RouteContextData, +} from '../../lib' + +export const toolTitle: string = ToolTitle.earn + +const EarnApp: FC<{}> = () => { + + const { getChildRoutes }: RouteContextData = useContext(routeContext) + + return ( + + + + {getChildRoutes(toolTitle)} + + + ) +} + +export default EarnApp diff --git a/src-ts/tools/earn-app/README.md b/src-ts/tools/earn-app/README.md new file mode 100644 index 000000000..6cf398fdf --- /dev/null +++ b/src-ts/tools/earn-app/README.md @@ -0,0 +1,3 @@ +# Instructions for Running the Earn Tool Locally + +## Earn Config \ No newline at end of file diff --git a/src-ts/tools/earn-app/earn.routes.tsx b/src-ts/tools/earn-app/earn.routes.tsx new file mode 100644 index 000000000..aeffdc390 --- /dev/null +++ b/src-ts/tools/earn-app/earn.routes.tsx @@ -0,0 +1,32 @@ +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '../../lib' + +import { toolTitle } from './EarnApp' + +const EarnAppRoot: LazyLoadedComponent = lazyLoad(() => import('./EarnApp')) +const ChallengeList: LazyLoadedComponent = lazyLoad( + () => import('../../../src/earn/routes/ChallengeList'), + 'ChallengeList', +) + +export enum EARN_APP_PATHS { + root = '/earn', +} + +export const rootRoute: string = EARN_APP_PATHS.root +export const absoluteRootRoute: string = `${window.location.origin}${EARN_APP_PATHS.root}` + +export const earnRoutes: ReadonlyArray = [ + { + children: [ + { + children: [], + element: , + id: 'Challenges Listing Page', + route: 'find/challenges', + }, + ], + element: , + id: toolTitle, + route: rootRoute, + }, +] diff --git a/src-ts/tools/earn-app/index.ts b/src-ts/tools/earn-app/index.ts new file mode 100644 index 000000000..4e06feeba --- /dev/null +++ b/src-ts/tools/earn-app/index.ts @@ -0,0 +1,4 @@ +export { + earnRoutes, + rootRoute as earnRootRoute, +} from './earn.routes' diff --git a/src-ts/tools/tools.routes.ts b/src-ts/tools/tools.routes.ts index 40e14309c..b21667415 100644 --- a/src-ts/tools/tools.routes.ts +++ b/src-ts/tools/tools.routes.ts @@ -1,6 +1,7 @@ import { PlatformRoute } from '../lib' import { devCenterRoutes } from './dev-center' +import { earnRoutes } from './earn-app' import { gamificationAdminRoutes } from './gamification-admin' import { learnRoutes } from './learn' import { workRoutes } from './work' @@ -13,6 +14,7 @@ const toolRoutes: ReadonlyArray = [ ...devCenterRoutes, ...learnRoutes, ...gamificationAdminRoutes, + ...earnRoutes, ] export default toolRoutes diff --git a/src/App.jsx b/src/App.jsx index 734a6a96f..d37f0a864 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,7 +14,6 @@ const WorkItem = lazyLoad(() => import("./routes/WorkItems")); const IntakeForm = lazyLoad(() => import("./IntakeForm")); const UnderMaintenance = lazyLoad(() => import("./routes/UnderMaintenance")); - const App = () => { const { initialized } = useContext(profileContext) diff --git a/src/assets/icons/banner-chevron-up.svg b/src/assets/icons/banner-chevron-up.svg new file mode 100644 index 000000000..96c5c3d89 --- /dev/null +++ b/src/assets/icons/banner-chevron-up.svg @@ -0,0 +1,17 @@ + + + banner chevron + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/find-work-green.svg b/src/assets/icons/find-work-green.svg new file mode 100644 index 000000000..fb4743f1a --- /dev/null +++ b/src/assets/icons/find-work-green.svg @@ -0,0 +1,16 @@ + + + 1D5681BC-C33F-4401-A8C9-A5A6F99E8CA7 + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/find-work.svg b/src/assets/icons/find-work.svg new file mode 100644 index 000000000..1af6abbaa --- /dev/null +++ b/src/assets/icons/find-work.svg @@ -0,0 +1,12 @@ + + + DAD5564D-C4AF-448B-9138-756872CA4CB8 + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/menu-chevron-up.svg b/src/assets/icons/menu-chevron-up.svg new file mode 100644 index 000000000..cfdb74002 --- /dev/null +++ b/src/assets/icons/menu-chevron-up.svg @@ -0,0 +1,17 @@ + + + CE319284-3A67-4756-B6AD-2D4164352632 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/my-work-green.svg b/src/assets/icons/my-work-green.svg new file mode 100644 index 000000000..3b944366f --- /dev/null +++ b/src/assets/icons/my-work-green.svg @@ -0,0 +1,11 @@ + + + 9178F574-02BA-4DFF-A4C4-93CE92BDE405 + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/my-work.svg b/src/assets/icons/my-work.svg new file mode 100644 index 000000000..b679bbba3 --- /dev/null +++ b/src/assets/icons/my-work.svg @@ -0,0 +1,15 @@ + + + C087C319-6EC3-4D78-A758-D1B398901B5D + + + + + + + + + + + + \ No newline at end of file diff --git a/src/earn/actions/challenge-listing/filter-panel.js b/src/earn/actions/challenge-listing/filter-panel.js new file mode 100644 index 000000000..97388f6c1 --- /dev/null +++ b/src/earn/actions/challenge-listing/filter-panel.js @@ -0,0 +1,23 @@ +/** + * Actions related to the header filter panel. + */ + +import _ from "lodash"; +import { createActions } from "redux-actions"; + +export default createActions({ + CHALLENGE_LISTING: { + FILTER_PANEL: { + /* Expands / collapses the filter panel. */ + SET_EXPANDED: _.identity, + + /* Updates text in the search bar, without applying it to the active + * challenge filter. The text will be set to the filter when Enter is + * pressed. */ + SET_SEARCH_TEXT: _.identity, + + /* Shows / hides the modal with track switches (for mobile view only). */ + SHOW_TRACK_MODAL: _.identity, + }, + }, +}); diff --git a/src/earn/actions/challenge-listing/index.js b/src/earn/actions/challenge-listing/index.js new file mode 100644 index 000000000..c7a464960 --- /dev/null +++ b/src/earn/actions/challenge-listing/index.js @@ -0,0 +1,54 @@ +/** + * Challenge listing actions. + */ + +import _ from "lodash"; +import { createActions } from "redux-actions"; +import "isomorphic-fetch"; +import { getService } from "../../services/challenges"; + +/** + * Gets possible challenge types. + * @return {Promise} + */ +function getChallengeTypesDone() { + return getService() + .getChallengeTypes() + .then((res) => res.sort((a, b) => a.name.localeCompare(b.name))); +} + +/** + * Gets possible challenge tags (technologies). + * @return {Promise} + */ +function getChallengeTagsDone() { + return getService() + .getChallengeTags() + .then((res) => + res.map((item) => item.name).sort((a, b) => a.localeCompare(b)) + ); +} + +export default createActions({ + CHALLENGE_LISTING: { + DROP_CHALLENGES: _.noop, + DROP_ACTIVE_CHALLENGES: _.noop, + DROP_OPEN_FOR_REGISTRATION_CHALLENGES: _.noop, + DROP_MY_CHALLENGES: _.noop, + DROP_ALL_CHALLENGES: _.noop, + DROP_PAST_CHALLENGES: _.noop, + DROP_RECOMMENDED_CHALLENGES: _.noop, + + GET_CHALLENGE_TYPES_INIT: _.noop, + GET_CHALLENGE_TYPES_DONE: getChallengeTypesDone, + + GET_CHALLENGE_TAGS_INIT: _.noop, + GET_CHALLENGE_TAGS_DONE: getChallengeTagsDone, + + EXPAND_TAG: (id) => id, + + SET_FILTER: _.identity, + + SET_SORT: (bucket, sort) => ({ bucket, sort }), + }, +}); diff --git a/src/earn/actions/challenge-listing/sidebar.js b/src/earn/actions/challenge-listing/sidebar.js new file mode 100644 index 000000000..adb2ffa25 --- /dev/null +++ b/src/earn/actions/challenge-listing/sidebar.js @@ -0,0 +1,158 @@ +/** + * Actions for the sidebar. + */ + +import _ from "lodash"; +import { createActions } from "redux-actions"; +// import { services } from 'topcoder-react-lib'; + +// const { getUserSettingsService } = services.userSetting; + +/** + * Changes name of the specified filter (but does not save it to the backend). + * @param {String} index + * @param {String} name + */ +// function changeFilterName(index, name) { +// return { index, name }; +// } + +/** + * Deletes saved filter. + * @param {String} id + * @param {Object} tokenV2 + * @return {Promise} + */ +// function deleteSavedFilter(id, tokenV2) { +// return getUserSettingsService(tokenV2) +// .deleteFilter(id).then(() => id); +// } + +/** + * Handles drag move event. + * @param {Object} dragEvent ReactJS onDrag event. + * @param {Object} dragState + * + * NOTE: This code is just taken from the previous version of the code. It has] + * some flaws, but it is not the main problem for now. + * + * NOTE: This implementation of dragging has a flaw: if you take an item and + * drug it down, you'll see that it is correctly moved down the list, but its + * highlighting (at least in Chrome) remains in the original position. Compare + * to the situation, when you drag an item upward the list: the highlighting + * moves properly with the item. This is related to the way ReactJS interacts + * with DOM, and, most probably, it is just easier to adopt some 3-rd party + * Drag-n-Drop library, then to find out a work-around. + */ +// function dragSavedFilterMove(dragEvent, dragState) { +/* For a reason not clear to me, shortly after starting to drag a filter, + * and also when the user releases the mouse button, thus ending the drag, + * this handler gets an event with 'screenY' position equal 0. This breaks + * the dragging handling, which works just fine otherwise. Hence, this simple + * fix of the issue, until the real problem is figured out. + */ +// if (!dragEvent.screenY) return dragState; + +// /* Calculation of the target position of the dragged item inside the filters +// * array. */ +// const shift = (dragEvent.screenY - dragState.y) / dragEvent.target.offsetHeight; +// const index = Math.round(dragState.startIndex + shift); +// if (index === dragState.index) return dragState; +// return { ...dragState, currentIndex: index }; +// } + +/** + * Initializes drag of a filter item. + * @param {Number} index + * @param {Object} dragEvent + * @return {Object} + */ +// function dragSavedFilterStart(index, dragEvent) { +// return { +// currentIndex: index, +// startIndex: index, +// y: dragEvent.screenY, +// }; +// } + +// function getSavedFilters(tokenV2) { +// return getUserSettingsService(tokenV2).getFilters(); +// } + +/** + * After changing filter name with changeFilterName(..) this action can be used + * to reset filter name to the one last saved into API. No API call is made, + * as the last saved name is kept inside the state. + * @param {String} index + */ +// function resetFilterName(index) { +// return index; +// } + +/** + * Saves filter to the backend. + * @param {String} name + * @param {Object} filter Filter state. + * @param {String} tokenV2 + * @return {Promise} + */ +// function saveFilter(name, filter, tokenV2) { +// return getUserSettingsService(tokenV2) +// .saveFilter(name, filter); +// } + +/** + * Updates all saved filters (basically to update their ordering in the + * backend). + * @param {Array} savedFilters + * @param {String} tokenV2 + */ +// function updateAllSavedFilters(savedFilters, tokenV2) { +// const service = getUserSettingsService(tokenV2); +// savedFilters.forEach(filter => service.updateFilter(filter.id, filter.name, filter.filter)); +// } + +/** + * Saves updated fitler to the backend. + * @param {Object} filter + * @param {String} tokenV2 + * @return {Promise} + */ +// function updateSavedFilter(filter, tokenV2) { +// return getUserSettingsService(tokenV2) +// .updateFilter(filter.id, filter.name, filter.filter); +// } + +export default createActions({ + CHALLENGE_LISTING: { + SIDEBAR: { + // CHANGE_FILTER_NAME: changeFilterName, + + // DELETE_SAVED_FILTER: deleteSavedFilter, + + // DRAG_SAVED_FILTER_MOVE: dragSavedFilterMove, + // DRAG_SAVED_FILTER_START: dragSavedFilterStart, + + // GET_SAVED_FILTERS: getSavedFilters, + + // RESET_FILTER_NAME: resetFilterName, + + // SAVE_FILTER_DONE: saveFilter, + + // SAVE_FILTER_INIT: _.noop, + + /* Pass in the bucket type. */ + SELECT_BUCKET: (bucket, expanding = false) => ({ bucket, expanding }), + SELECT_BUCKET_DONE: _.noop, + + /* Pass in the index of filter inside savedFilters array. */ + // SELECT_SAVED_FILTER: _.identity, + + /* Pass in true/false to enable/disable. */ + // SET_EDIT_SAVED_FILTERS_MODE: _.identity, + + // UPDATE_ALL_SAVED_FILTERS: updateAllSavedFilters, + // UPDATE_SAVED_FILTER: updateSavedFilter, + }, + }, +}); diff --git a/src/earn/actions/challenge.js b/src/earn/actions/challenge.js new file mode 100644 index 000000000..95f3dee6f --- /dev/null +++ b/src/earn/actions/challenge.js @@ -0,0 +1,576 @@ +/** + * @module "reducers.challenge" + * @desc Reducer for {@link module:actions.challenge} actions. + * + * State segment managed by this reducer has the following strcuture: + * @todo Document the structure. + */ + +import _ from "lodash"; + +import { handleActions } from "redux-actions"; +import { combineReducers } from "../utils/redux"; + +import actions from "../actions/challenge"; +import smpActions from "../actions/smp"; +import logger from "../utils/logger"; +import { fireErrorMessage } from "../utils/errors"; + +import mySubmissionsManagement from "./my-submissions-management"; + +import { COMPETITION_TRACKS } from "../utils/tc"; + +/** + * Handles CHALLENGE/GET_BASIC_DETAILS_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state + */ +function onGetBasicDetailsInit(state, action) { + const challengeId = action.payload; + return state.details && _.toString(state.details.id) !== challengeId + ? { + ...state, + fetchChallengeFailure: false, + loadingDetailsForChallengeId: challengeId, + details: null, + } + : { + ...state, + fetchChallengeFailure: false, + loadingDetailsForChallengeId: challengeId, + }; +} + +/** + * Handles CHALLENGE/GET_BASIC_DETAILS_DONE action. + * Note, that it silently discards received details if the ID of received + * challenge mismatches the one stored in loadingDetailsForChallengeId field + * of the state. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetBasicDetailsDone(state, action) { + if (action.error) { + logger.error("Failed to get challenge details!", action.payload); + fireErrorMessage( + "ERROR: Failed to load the challenge", + "Please, try again a bit later" + ); + return { + ...state, + fetchChallengeFailure: action.error, + loadingDetailsForChallengeId: "", + }; + } + + const details = action.payload; + + // condition based on ROUTE used for Review Opportunities, change if needed + const challengeId = state.loadingDetailsForChallengeId; + let compareChallenge = details.id; + if (challengeId.length >= 5 && challengeId.length <= 8) { + compareChallenge = details.legacyId; + } + + if (_.toString(compareChallenge) !== challengeId) { + return state; + } + + return { + ...state, + details, + fetchChallengeFailure: false, + loadingDetailsForChallengeId: "", + }; +} + +/** + * Handles CHALLENGE/GET_FULL_DETAILS_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state + */ +function onGetFullDetailsInit(state, action) { + const challengeId = action.payload; + return { + ...state, + fetchChallengeFailure: false, + loadingFullDetailsForChallengeId: challengeId, + }; +} + +/** + * Handles CHALLENGE/GET_FULL_DETAILS_DONE action. + * Note, that it silently discards received details if the ID of received + * challenge mismatches the one stored in loadingFullDetailsForChallengeId field + * of the state. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetFullDetailsDone(state, action) { + if (action.error) { + logger.error("Failed to get full challenge details!", action.payload); + fireErrorMessage( + "ERROR: Failed to load the challenge", + "Please, try again a bit later" + ); + return { + ...state, + fetchChallengeFailure: action.error, + loadingFullDetailsForChallengeId: "", + }; + } + + const details = action.payload; + + // condition based on ROUTE used for Review Opportunities, change if needed + const challengeId = state.loadingFullDetailsForChallengeId; + let compareChallenge = details.id; + if (challengeId.length >= 5 && challengeId.length <= 8) { + compareChallenge = details.legacyId; + } + + if (_.toString(compareChallenge) !== challengeId) { + return state; + } + + return { + ...state, + details, + fetchChallengeFailure: false, + loadingFullDetailsForChallengeId: "", + }; +} + +/** + * Handles CHALLENGE/GET_SUBMISSION_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetSubmissionsInit(state, action) { + return { + ...state, + loadingSubmissionsForChallengeId: action.payload, + mySubmissions: { challengeId: "", v2: null }, + }; +} + +/** + * Handles challengeActions.fetchSubmissionsDone action. + * @param {Object} state Previous state. + * @param {Object} action Action. + */ +function onGetSubmissionsDone(state, action) { + if (action.error) { + logger.error( + "Failed to get user's submissions for the challenge", + action.payload + ); + return { + ...state, + loadingSubmissionsForChallengeId: "", + mySubmissions: { challengeId: "", v2: null }, + }; + } + + const { challengeId, submissions } = action.payload; + if (challengeId !== state.loadingSubmissionsForChallengeId) return state; + + return { + ...state, + loadingSubmissionsForChallengeId: "", + mySubmissions: { challengeId, v2: submissions }, + }; +} + +/** + * Handles CHALLENGE/GET_MM_SUBMISSION_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetMMSubmissionsInit(state, action) { + return { + ...state, + loadingMMSubmissionsForChallengeId: action.payload, + mmSubmissions: [], + }; +} + +/** + * Handles CHALLENGE/GET_MM_SUBMISSION_DONE action. + * @param {Object} state Previous state. + * @param {Object} action Action. + */ +function onGetMMSubmissionsDone(state, action) { + if (action.error) { + logger.error( + "Failed to get Marathon Match submissions for the challenge", + action.payload + ); + return { + ...state, + loadingMMSubmissionsForChallengeId: "", + mmSubmissions: [], + }; + } + + const { challengeId, submissions } = action.payload; + if (challengeId.toString() !== state.loadingMMSubmissionsForChallengeId) + return state; + return { + ...state, + loadingMMSubmissionsForChallengeId: "", + mmSubmissions: submissions, + }; +} + +/** + * Handles challengeActions.fetchCheckpointsDone action. + * @param {Object} state Previous state. + * @param {Object} action Action. + */ +function onFetchCheckpointsDone(state, action) { + if (action.error) { + return { + ...state, + loadingCheckpoints: false, + }; + } + if ( + state.details && + `${state.details.legacyId}` === `${action.payload.challengeId}` + ) { + return { + ...state, + checkpoints: action.payload.checkpoints, + loadingCheckpoints: false, + }; + } + return state; +} + +/** + * Handles CHALLENGE/LOAD_RESULTS_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onLoadResultsInit(state, { payload }) { + return { ...state, loadingResultsForChallengeId: payload }; +} + +/** + * Handles CHALLENGE/LOAD_RESULTS_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onLoadResultsDone(state, action) { + if (action.payload.challengeId !== state.loadingResultsForChallengeId) { + return state; + } + if (action.error) { + logger.error(action.payload); + return { + ...state, + loadingResultsForChallengeId: "", + results: null, + resultsLoadedForChallengeId: "", + }; + } + return { + ...state, + loadingResultsForChallengeId: "", + results: action.payload.results, + resultsLoadedForChallengeId: action.payload.challengeId, + }; +} + +/** + * Handles CHALLENGE/REGISTER_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onRegisterDone(state, action) { + if (action.error) { + logger.error("Failed to register for the challenge!", action.payload); + fireErrorMessage("ERROR: Failed to register for the challenge!"); + return { ...state, registering: false }; + } + /* As a part of registration flow we silently update challenge details, + * reusing for this purpose the corresponding action handler. Thus, we + * should also reuse corresponding reducer to generate proper state. */ + return onGetBasicDetailsDone( + { + ...state, + registering: false, + loadingDetailsForChallengeId: _.toString(state.details.id), + }, + action + ); +} + +/** + * Handles CHALLENGE/UNREGISTER_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} + */ +function onUnregisterDone(state, action) { + if (action.error) { + logger.error("Failed to register for the challenge!", action.payload); + fireErrorMessage("ERROR: Failed to unregister for the challenge!"); + return { ...state, unregistering: false }; + } + /* As a part of unregistration flow we silently update challenge details, + * reusing for this purpose the corresponding action handler. Thus, we + * should also reuse corresponding reducer to generate proper state. */ + return onGetBasicDetailsDone( + { + ...state, + unregistering: false, + loadingDetailsForChallengeId: _.toString(state.details.id), + }, + action + ); +} + +/** + * Handles CHALLENGE/UPDATE_CHALLENGE_INIT. + * @param {Object} state Old state. + * @param {Object} actions Action. + * @return {Object} New state. + */ +function onUpdateChallengeInit(state, { payload }) { + return { ...state, updatingChallengeUuid: payload }; +} + +/** + * Handles CHALLENGE/UPDATE_CHALLENGE_DONE. + * @param {Object} state Old state. + * @param {Object} actions Action. + * @return {Object} New state. + */ +function onUpdateChallengeDone(state, { error, payload }) { + if (error) { + fireErrorMessage("Failed to save the challenge!", ""); + logger.error("Failed to save the challenge", payload); + return state; + } + if (payload.uuid !== state.updatingChallengeUuid) return state; + + /* Due to the normalization of challenge APIs responses done when a challenge + * is loaded, many pieces of our code expect different information in a format + * different from API v3 response, thus if we just save entire payload.res + * into the Redux state segment, it will break our app. As a rapid fix, let's + * just save only the data which are really supposed to be updated in the + * current use case (editing of challenge specs). */ + const res = _.pick(payload.res, [ + "detailedRequirements", + "introduction", + "round1Introduction", + "round2Introduction", + "submissionGuidelines", + ]); + + return { + ...state, + details: { + ...state.details, + ...res, + }, + updatingChallengeUuid: "", + }; +} + +/** + * Handles CHALLENGE/GET_ACTIVE_CHALLENGES_COUNT_DONE action. + * @param {Object} state Old state. + * @param {Object} action Action payload/error + * @return {Object} New state + */ +function onGetActiveChallengesCountDone(state, { payload, error }) { + if (error) { + fireErrorMessage("Failed to get active challenges count!", ""); + logger.error("Failed to get active challenges count", payload); + return state; + } + + return { ...state, activeChallengesCount: payload }; +} + +/** + * Handles CHALLENGE/GET_SUBMISSION_INFORMATION_INIT action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetSubmissionInformationInit(state, action) { + return { + ...state, + loadingSubmissionInformationForChallengeId: action.payload.challengeId, + loadingSubmissionInformationForSubmissionId: action.payload.submissionId, + submissionInformation: null, + }; +} + +/** + * Handles CHALLENGE/GET_SUBMISSION_INFORMATION_DONE action. + * @param {Object} state Previous state. + * @param {Object} action Action. + */ +function onGetSubmissionInformationDone(state, action) { + if (action.error) { + logger.error("Failed to get submission information", action.payload); + return { + ...state, + loadingSubmissionInformationForSubmissionId: "", + submissionInformation: null, + }; + } + + const { submissionId, submission } = action.payload; + if (submissionId !== state.loadingSubmissionInformationForSubmissionId) + return state; + + return { + ...state, + loadingSubmissionInformationForSubmissionId: "", + submissionInformation: submission, + }; +} + +function onGetChallengeInit(state) { + return { + ...state, + isLoadingChallenge: true, + isChallengeLoaded: false, + }; +} + +function onGetChallengeDone(state, { error, payload }) { + if (error) { + logger.error("Failed to get challenge details!", payload); + fireErrorMessage( + "ERROR: Failed to load the challenge", + "Please, try again a bit later" + ); + return { ...state, isLoadingChallenge: false, isChallengeLoaded: false }; + } + + return { + ...state, + challenge: { ...payload }, + isLoadingChallenge: false, + isChallengeLoaded: true, + }; +} + +/** + * Update isRegistered to before challenge submit + * @param {Object} state Old state. + * @param {Object} actions Action error/payload. + * @param {Object} action Action. + */ +function onGetIsRegistered(state, { error, payload }) { + if (error) { + logger.error("Failed to get the user's registration status!", payload); + fireErrorMessage( + "ERROR: Failed to submit", + "Please, try again a bit later" + ); + return state; + } + return { + ...state, + challenge: { + ...state.challenge, + isRegistered: payload.isRegistered, + }, + }; +} + +/** + * Creates a new Challenge reducer with the specified initial state. + * @param {Object} initialState Optional. Initial state. + * @return {Function} Challenge reducer. + */ +function create(initialState) { + const a = actions.challenge; + return handleActions( + { + [a.dropCheckpoints]: (state) => ({ ...state, checkpoints: null }), + [a.dropResults]: (state) => ({ ...state, results: null }), + [a.getBasicDetailsInit]: onGetBasicDetailsInit, + [a.getBasicDetailsDone]: onGetBasicDetailsDone, + [a.getFullDetailsInit]: onGetFullDetailsInit, + [a.getFullDetailsDone]: onGetFullDetailsDone, + [a.getSubmissionsInit]: onGetSubmissionsInit, + [a.getSubmissionsDone]: onGetSubmissionsDone, + [a.getMmSubmissionsInit]: onGetMMSubmissionsInit, + [a.getMmSubmissionsDone]: onGetMMSubmissionsDone, + [smpActions.smp.deleteSubmissionDone]: (state, { payload }) => ({ + ...state, + mySubmissions: { + ...state.mySubmissions, + v2: state.mySubmissions.v2.filter( + (subm) => subm.submissionId !== payload + ), + }, + }), + [a.registerInit]: (state) => ({ ...state, registering: true }), + [a.registerDone]: onRegisterDone, + [a.unregisterInit]: (state) => ({ ...state, unregistering: true }), + [a.unregisterDone]: onUnregisterDone, + [a.loadResultsInit]: onLoadResultsInit, + [a.loadResultsDone]: onLoadResultsDone, + [a.fetchCheckpointsInit]: (state) => ({ + ...state, + checkpoints: null, + loadingCheckpoints: true, + }), + [a.fetchCheckpointsDone]: onFetchCheckpointsDone, + [a.updateChallengeInit]: onUpdateChallengeInit, + [a.updateChallengeDone]: onUpdateChallengeDone, + [a.getActiveChallengesCountInit]: (state) => state, + [a.getActiveChallengesCountDone]: onGetActiveChallengesCountDone, + [a.getSubmissionInformationInit]: onGetSubmissionInformationInit, + [a.getSubmissionInformationDone]: onGetSubmissionInformationDone, + [a.getChallengeInit]: onGetChallengeInit, + [a.getChallengeDone]: onGetChallengeDone, + [a.getIsRegistered]: onGetIsRegistered, + }, + _.defaults(initialState, { + details: null, + loadingCheckpoints: false, + loadingDetailsForChallengeId: "", + loadingFullDetailsForChallengeId: "", + loadingResultsForChallengeId: "", + loadingMMSubmissionsForChallengeId: "", + loadingSubmissionInformationForSubmissionId: "", + mySubmissions: {}, + checkpoints: null, + registering: false, + results: null, + resultsLoadedForChallengeId: "", + unregistering: false, + updatingChallengeUuid: "", + mmSubmissions: [], + submissionInformation: null, + isLoadingChallenge: false, + }) + ); +} + +/** + * @static + * @member default + * @desc Reducer with default intial state. + */ +export default combineReducers(create(), { mySubmissionsManagement }); diff --git a/src/earn/actions/challenges.js b/src/earn/actions/challenges.js new file mode 100644 index 000000000..4cdbfb021 --- /dev/null +++ b/src/earn/actions/challenges.js @@ -0,0 +1,92 @@ +import { createActions } from "redux-actions"; +import _ from "lodash"; +import service from "../services/challenges"; +import * as util from "../utils/challenge"; +import * as constants from "../constants"; + +async function doGetChallenges(filter, cancellationSignal) { + return service.getChallenges(filter, cancellationSignal); +} + +async function getAllActiveChallenges(filter, signal) { + const allActiveFilter = { + ...util.createChallengeCriteria(filter), + ...util.createAllActiveChallengeCriteria(), + }; + return doGetChallenges(allActiveFilter, signal); +} + +async function getOpenForRegistrationChallenges(filter, signal) { + const openForRegistrationFilter = { + ...util.createChallengeCriteria(filter), + ...util.createOpenForRegistrationChallengeCriteria(), + }; + return doGetChallenges(openForRegistrationFilter, signal); +} + +async function getClosedChallenges(filter, signal) { + const closedFilter = { + ...util.createChallengeCriteria(filter), + ...util.createClosedChallengeCriteria(), + }; + return doGetChallenges(closedFilter, signal); +} + +async function getOpenForRegistrationCount(filter, signal) { + const openForRegistrationCountCriteria = { + ...util.createChallengeCriteria(filter), + ...util.createOpenForRegistrationCountCriteria(), + }; + return doGetChallenges(openForRegistrationCountCriteria, signal); +} + +async function getChallenges(filter, signal) { + const ALL_ACTIVE_CHALLENGES_BUCKET = constants.FILTER_BUCKETS[0]; + const OPEN_FOR_REGISTRATION_BUCKET = constants.FILTER_BUCKETS[1]; + const CLOSED_CHALLENGES = constants.FILTER_BUCKETS[2]; + + let challenges; + let total; + let openForRegistrationCount; + + const getChallengesByBucket = async (f) => { + const promises = []; + switch (f.bucket) { + case ALL_ACTIVE_CHALLENGES_BUCKET: + promises.push(getAllActiveChallenges(f, signal)); + break; + case OPEN_FOR_REGISTRATION_BUCKET: + promises.push(getOpenForRegistrationChallenges(f, signal)); + break; + case CLOSED_CHALLENGES: + promises.push(getClosedChallenges(f, signal)); + break; + default: + return [util.createEmptyResult(), 0]; + } + promises.push(getOpenForRegistrationCount(f, signal)); + return Promise.all(promises).then((result) => [ + result[0], + result[1].meta.total, + ]); + }; + + if (!util.checkRequiredFilterAttributes(filter)) { + return { + challenges: [], + total: 0, + openForRegistrationCount: 0, + }; + } + + [challenges, openForRegistrationCount] = await getChallengesByBucket(filter); + total = challenges.meta.total; + + return { challenges, total, openForRegistrationCount }; +} + +export default createActions({ + GET_CHALLENGES_INIT: _.noop(), + GET_CHALLENGES_DONE: getChallenges, + GET_CHALLENGES_FAILURE: _.noop, +}); diff --git a/src/earn/actions/filter.js b/src/earn/actions/filter.js new file mode 100644 index 000000000..f4cbf1e2a --- /dev/null +++ b/src/earn/actions/filter.js @@ -0,0 +1,22 @@ +import { createActions } from "redux-actions"; +import * as utils from "../utils"; + +function updateFilter(partialUpdate) { + return partialUpdate; +} + +function clearChallengeFilter(defaultFilter) { + return defaultFilter; +} + +function updateChallengeQuery(filter) { + const params = utils.challenge.createChallengeParams(filter); + utils.url.updateQuery(params); + return params; +} + +export default createActions({ + UPDATE_FILTER: updateFilter, + CLEAR_CHALLENGE_FILTER: clearChallengeFilter, + UPDATE_CHALLENGE_QUERY: updateChallengeQuery, +}); diff --git a/src/earn/actions/index.js b/src/earn/actions/index.js new file mode 100644 index 000000000..f5602a81b --- /dev/null +++ b/src/earn/actions/index.js @@ -0,0 +1,7 @@ +import challenges from "./challenges"; +import filter from "./filter"; + +export const actions = { + challenges, + filter, +}; diff --git a/src/earn/components/Banner/index.jsx b/src/earn/components/Banner/index.jsx new file mode 100644 index 000000000..e1c7135af --- /dev/null +++ b/src/earn/components/Banner/index.jsx @@ -0,0 +1,47 @@ +import React, { useState } from "react"; +import BannerChevronUp from "../../../assets/icons/banner-chevron-up.svg"; + +import "./styles.scss"; + +const Banner = () => { + const [isExpanded, setIsExpanded] = useState(false); + const header = + "Welcome to our BETA work listings site - Tell us what you think!"; + + return ( +
+

+ {header} + + setIsExpanded(!isExpanded)} + > + + +

+ + {isExpanded && ( +
+

+ Welcome to the Beta version of the new Challenge Listings. During + this Beta phase, we will be fine-tuning the platform based on + feedback we receive from you, our community members. +

+

NOTE THAT THIS IS NOT THE FINAL VERSION OF THE SITE.

+

+ You may encounter occasional broken links or error messages. If so, + please let us know! This is what the Beta phase is intended for, and + your feedback will enable us to greatly improve the new site.{" "} +

+

You can click on the Feedback button on page.

+

Thank you!

+
+ )} +
+ ); +}; + +export default Banner; diff --git a/src/earn/components/Banner/styles.scss b/src/earn/components/Banner/styles.scss new file mode 100644 index 000000000..704c6b10c --- /dev/null +++ b/src/earn/components/Banner/styles.scss @@ -0,0 +1,65 @@ +@import "../../../styles/variables"; +@import "../../../styles/mixins"; + +.banner { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + + width: 100%; + background: linear-gradient(90deg, $tc-blue 0%, $tc-turquoise 100%); + border-radius: 10px; + margin-bottom: 22px; + color: $tc-white; + padding-left: 28px; + + .header { + display: flex; + width: 100%; + justify-content: space-between; + flex-direction: row; + align-items: center; + @include roboto-bold; + line-height: 25px; + min-height: 50px; + font-size: 20px; + text-transform: uppercase; + } + + .chevron { + margin-right: 20px; + margin-top: 5px; + + &.expanded { + transform: rotate(180deg); + } + + &:hover { + cursor: pointer; + } + } + + .content { + display: flex; + flex-direction: column; + justify-content: flex-start; + font-size: 16px; + margin-bottom: 24px; + width: 85%; + + h3 { + font-size: 15px; + font-weight: bold; + margin-top: 15px; + } + + p { + margin-top: 15px; + } + + a { + text-decoration: underline; + } + + } +} diff --git a/src/earn/components/Menu/index.jsx b/src/earn/components/Menu/index.jsx new file mode 100644 index 000000000..7f74687e2 --- /dev/null +++ b/src/earn/components/Menu/index.jsx @@ -0,0 +1,180 @@ +import React, { useRef, useEffect } from "react"; +import { navigate } from "@reach/router"; +import PT from "prop-types"; +import _ from "lodash"; + +import IconChevronUp from "../../../assets/icons/menu-chevron-up.svg"; +import { MenuSelection, getMenuIcon } from "../../utils"; + +import "./styles.scss"; + +const Menu = ({ menu, selected, onSelect, isLoggedIn, onUpdateMenu }) => { + const selectionRef = useRef(); + if (!selectionRef.current) { + selectionRef.current = new MenuSelection( + _.cloneDeep(menu), + selected, + onSelect, + onUpdateMenu + ); + } + + useEffect(() => { + selectionRef.current.setMenu(menu); + }, [menu]); + + useEffect(() => { + selectionRef.current.select(selected); + }, [selected]); + + // useEffect(() => { + // if (selectionRef.current.isAuth(selected) && isLoggedIn === false) { + // utils.auth.logIn(); + // } + // }, [selected, isLoggedIn]); + + const onSelectMenuItem = (name, path) => { + selectionRef.current.select(name); + if (path) { + navigate(path); + } + }; + + const getIcon = (menuItem, active) => { + const name = active ? menuItem.iconActive : menuItem.icon; + return getMenuIcon(name); + }; + + const isExpandable = (menuItem) => + selectionRef.current.isExpandable(menuItem); + const isSelected = (menuItem) => selectionRef.current.isSelected(menuItem); + const isExpanded = (menuItem) => selectionRef.current.isExpanded(menuItem); + const isActive = (menuItem) => selectionRef.current.isActive(menuItem); + + const renderSubSubmenu = (subMenuItem) => { + return ( +
    + {subMenuItem.children.map((subSubmenuItem) => ( +
  • + { + onSelectMenuItem(subSubmenuItem.name, subSubmenuItem.path); + }} + > + {subSubmenuItem.name} + +
  • + ))} +
+ ); + }; + + const renderSubmenu = (menuItem) => { + if (!menuItem.children) { + return null; + } + + return ( +
    + {menuItem.children.map((subMenuItem) => ( +
  • + { + onSelectMenuItem( + subMenuItem.name, + isExpandable(subMenuItem) ? null : subMenuItem.path + ); + }} + > + {subMenuItem.name} + + {isExpandable(subMenuItem) && renderSubSubmenu(subMenuItem)} +
  • + ))} +
+ ); + }; + + return ( + + ); +}; + +Menu.propTypes = { + menu: PT.shape(), + selected: PT.string, + onSelect: PT.func, + isLoggedIn: PT.oneOf([null, true, false]), +}; + +export default Menu; diff --git a/src/earn/components/Menu/styles.scss b/src/earn/components/Menu/styles.scss new file mode 100644 index 000000000..721340e20 --- /dev/null +++ b/src/earn/components/Menu/styles.scss @@ -0,0 +1,102 @@ +@import "../../../styles/variables"; + +$menu-padding-x: 4 * $base-unit; +$menu-padding-y: 20px; + +.menu { + padding: $menu-padding-y $menu-padding-x (3 * $base-unit); + + &.logged-in {} + + &.logged-out { + .menu-item-auth { + display: none; + } + } +} + +.menu-item { + padding: 4px 0; + cursor: pointer; + + .link { + display: flex; + align-items: center; + line-height: 26px; + outline: none; + } + + .icon { + width: 24px; + height: 24px; + margin-right: 16px; + text-align: left; + } + + .text {} + + .arrow { + width: 21px; + height: 21px; + margin-left: 8px; + line-height: 1; + text-align: center; + vertical-align: middle; + + &.up {} + &.down { + transform: rotate(180deg); + } + } + + &.active > .link { + font-weight: 500; + } + + &.selected > .link { + color: $tc-turquoise-dark1; + } +} + +.menu-item-main > .link { + margin-left: -20px; + margin-right: -20px; + padding-left: 20px; + padding-right: 20px; +} + +.menu-item-main.active > .link { + box-shadow: inset 4px 0 $tc-turquoise; +} + +.menu-item-main > .link + ul, +.menu-item-secondary > .link + ul { + display: none; +} + +.menu-item-main.expanded > .link + ul, +.menu-item-secondary.expanded > .link + ul { + display: block; + cursor: default; +} + +.menu-item-secondary.active.collapsed { + color: $tc-turquoise-dark1; +} + +.sub-menu { + padding-left: 24px + 16px; + + .menu-item { + cursor: default; + .link { + cursor: pointer; + display: inline-block; + } + } + +} + +.sub-submenu { + padding-left: 20px; +} diff --git a/src/earn/components/Panel/index.jsx b/src/earn/components/Panel/index.jsx new file mode 100644 index 000000000..760fde87b --- /dev/null +++ b/src/earn/components/Panel/index.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import PT from "prop-types"; + +import "./styles.scss"; + +const Panel = ({ children }) =>
{children}
; + +Panel.propTypes = { + children: PT.node, +}; + +const PanelHeader = ({ children }) => ( +
{children}
+); + +PanelHeader.propTypes = { + children: PT.node, +}; + +const PanelBody = ({ children }) => ( +
{children}
+); + +PanelBody.propTypes = { + children: PT.node, +}; + +const PanelFooter = ({ children }) => ( +
{children}
+); + +PanelFooter.propTypes = { + children: PT.node, +}; + +Panel.Header = PanelHeader; +Panel.Body = PanelBody; +Panel.Footer = PanelFooter; + +export default Panel; diff --git a/src/earn/components/Panel/styles.scss b/src/earn/components/Panel/styles.scss new file mode 100644 index 000000000..2bc6d23f5 --- /dev/null +++ b/src/earn/components/Panel/styles.scss @@ -0,0 +1,41 @@ +@import "../../../styles/variables"; +@import "../../../styles/mixins"; + +$panel-padding-x: 24px; +$panel-padding-y: 16px; + +.panel { + background: $white; + border-radius: $border-radius-lg; + + @include down($mfe-screen-xs) { + padding: 0; + border-radius: 0; + + .panel-header, + .panel-footer { + padding: 10px 20px; + border-radius: 0; + } + + .panel-body { + padding: 0; + } + } +} + +.panel-header { + padding: $panel-padding-y $panel-padding-x 0; + border-top-left-radius: $border-radius-lg; + border-top-right-radius: $border-radius-lg; +} + +.panel-body { + padding: 0 $panel-padding-x; +} + +.panel-footer { + padding: 0 $panel-padding-x $panel-padding-y; + border-bottom-left-radius: $border-radius-lg; + border-bottom-right-radius: $border-radius-lg; +} diff --git a/src/earn/constants/challenges.js b/src/earn/constants/challenges.js new file mode 100644 index 000000000..c6c8cf83f --- /dev/null +++ b/src/earn/constants/challenges.js @@ -0,0 +1,104 @@ +export const PAGINATION_PER_PAGES = [10, 20, 50]; +export const PAGINATION_MAX_PAGE_DISPLAY = 3; + +/* + * Challenge Status + */ +export const CHALLENGE_STATUS = { + ACTIVE: "Active", + CANCELLED: "Cancelled", + COMPLETED: "Completed", + DRAFT: "Draft", +}; + +export const FILTER_BUCKETS = [ + "All Active Challenges", + "Open for Registration", + "Closed Challenges", +]; + +export const FILTER_CHALLENGE_TYPES = ["Challenge", "First2Finish", "Task"]; + +export const FILTER_CHALLENGE_TYPE_ABBREVIATIONS = { + Challenge: "CH", + First2Finish: "F2F", + Task: "TSK", +}; + +export const FILTER_CHALLENGE_TRACKS = [ + "Design", + "Development", + "Data Science", + "Quality Assurance", +]; + +export const FILTER_CHALLENGE_TRACK_ABBREVIATIONS = { + Design: "DES", + Development: "DEV", + "Data Science": "DS", + "Quality Assurance": "QA", +}; + +export const CHALLENGE_SORT_BY = { + // "Best Match": "bestMatch", + "Most recent": "updated", + "Prize amount": "overview.totalPrizes", + Title: "name", +}; + +export const CHALLENGE_SORT_BY_RECOMMENDED = "bestMatch"; +export const CHALLENGE_SORT_BY_RECOMMENDED_LABEL = "Best Match"; +export const CHALLENGE_SORT_BY_MOST_RECENT = "updated"; +export const CHALLENGE_SORT_ORDER_DEFAULT = "desc"; +export const CHALLENGES_URL = "/earn/find/challenges"; + +export const SORT_ORDER = { + DESC: "desc", + ASC: "asc", +}; + +export const SORT_BY_SORT_ORDER = { + // bestMatch: SORT_ORDER.DESC, + updated: SORT_ORDER.DESC, + "overview.totalPrizes": SORT_ORDER.DESC, + name: SORT_ORDER.ASC, +}; + +export const TRACK_COLOR = { + Design: "#2984BD", + Development: "#35AC35", + "Data Science": "#F46500", + "Quality Assurance": "#35AC35", +}; + +export const CURRENCY_SYMBOL = { + EUR: "€", + INR: "₹", + USD: "$", +}; + +export const ACCESS_DENIED_REASON = { + NOT_AUTHENTICATED: "Not authenticated", + NOT_AUTHORIZED: "Not authorized", + HAVE_NOT_SUBMITTED_TO_THE_CHALLENGE: + "You have not submitted to this challenge", +}; + +/** + * Codes of the Topcoder communities. + */ +/* TODO: These are originally motivated by Topcoder API v2. Topcoder API v3 + * uses upper-case literals to encode the tracks. At some point, we should + * update it in this code as well! */ +export const COMPETITION_TRACKS = { + DS: "Data Science", + DES: "Design", + DEV: "Development", + QA: "Quality Assurance", +}; + +export const TOKEN_COOKIE_KEYS = { + V3JWT: "v3jwt", + TCJWT: "tcjwt", + TCSSO: "tcsso", +}; diff --git a/src/earn/constants/index.js b/src/earn/constants/index.js new file mode 100644 index 000000000..89327dab3 --- /dev/null +++ b/src/earn/constants/index.js @@ -0,0 +1,2 @@ +export * from './challenges'; +export * from './menu'; diff --git a/src/earn/constants/menu.js b/src/earn/constants/menu.js new file mode 100644 index 000000000..ed00e5ef2 --- /dev/null +++ b/src/earn/constants/menu.js @@ -0,0 +1,33 @@ +/* --- MENU --- */ +export const NAV_MENU = { + children: [ + { + name: "My Work", + icon: "my-work.svg", + iconActive: "my-work-green.svg", + auth: true, + children: [ + { + name: "My Gigs", + path: "/earn/my-gigs", + }, + ], + }, + { + name: "Find Work", + icon: "find-work.svg", + iconActive: "find-work-green.svg", + children: [ + { + name: "Gigs", + path: "/earn/gigs", + }, + { + name: "Challenges", + path: "/earn/find/challenges", + }, + ], + }, + ], + }; + \ No newline at end of file diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/index.jsx new file mode 100644 index 000000000..62819cce0 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/index.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import PT from "prop-types"; +import IconRegistrant from "../../../../../assets/icons/registrant.svg"; + +import "./styles.scss"; + +const NumRegistrants = ({ numOfRegistrants }) => ( +
+ {numOfRegistrants} +
+); + +NumRegistrants.propTypes = { + numOfRegistrants: PT.number, +}; + +export default NumRegistrants; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss new file mode 100644 index 000000000..203ecfbbc --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss @@ -0,0 +1,15 @@ +@import "styles/variables"; + +.registrants { + display: inline-block; + font-size: $font-size-sm; + line-height: 22px; + + svg { + display: inline-block; + width: 14px; + height: 16px; + margin-right: 7px; + vertical-align: middle; + } +} diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/index.jsx new file mode 100644 index 000000000..8530cdad2 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/index.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import PT from "prop-types"; +import IconSubmission from "../../../../../assets/icons/submission.svg"; + +import "./styles.scss"; + +const NumSubmissions = ({ numOfSubmissions }) => ( +
+ {numOfSubmissions} +
+); + +NumSubmissions.propTypes = { + numOfSubmissions: PT.number, +}; + +export default NumSubmissions; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss new file mode 100644 index 000000000..38d8d38a2 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss @@ -0,0 +1,15 @@ +@import "styles/variables"; + +.submissions { + display: inline-block; + font-size: $font-size-sm; + line-height: 22px; + + svg { + display: inline-block; + width: 14px; + height: 16px; + margin-right: 7px; + vertical-align: middle; + } +} diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx new file mode 100644 index 000000000..037c988aa --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx @@ -0,0 +1,60 @@ +import React from "react"; +import PT from "prop-types"; +import * as utils from "../../../../../utils"; + +import "./styles.scss"; + +const PhaseEndDate = ({ challenge, tooltip }) => { + const active = challenge.status === "Active"; + const endDate = utils.challenge.getEndDate(challenge); + const phaseMessage = + challenge.status === "Completed" + ? "" + : utils.challenge.getActivePhaseMessage(challenge); + const hasStatusPhase = utils.challenge.getStatusPhase(challenge) != null; + const phaseTimeLeft = utils.challenge.getActivePhaseTimeLeft(challenge); + const timeLeftColor = + phaseTimeLeft.time < 12 * 60 * 60 * 1000 ? "#EF476F" : ""; + const timeLeftMessage = phaseTimeLeft.late ? ( + + Late by + {` ${phaseTimeLeft.text}`} + + ) : ( + + {`${phaseTimeLeft.text} `} + {phaseTimeLeft.late === false && to go} + + ); + + const Tooltip = tooltip; + + return ( + + + {`${phaseMessage}`} {`${active ? "Ends" : "Ended"}`}: + {" "} + + + {active && hasStatusPhase ? timeLeftMessage : endDate} + + + + ); +}; + +PhaseEndDate.defaultProps = { + tooltip: ({ children }) => <>{children}, +}; + +PhaseEndDate.propTypes = { + challenge: PT.shape(), + tooltip: PT.oneOfType([PT.node, PT.func]), +}; + +export default PhaseEndDate; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/styles.scss new file mode 100644 index 000000000..e1d9ff500 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/styles.scss @@ -0,0 +1,23 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.phase-message { + font-size: $font-size-sm; + line-height: 20px; + color: $tc-gray-70; +} + +.time-left { + @include barlow-semibold; + + line-height: 20px; + + small { + @include roboto-regular; + font-size: $font-size-sm; + } + + .uppercase { + text-transform: uppercase; + } +} diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/index.jsx new file mode 100644 index 000000000..11bb944ac --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/index.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import PT from "prop-types"; +import * as utils from "../../../../../utils"; + +import "./styles.scss"; + +const Prize = ({ totalPrizes, currencySymbol }) => ( +
+ Purse + + {utils.formatMoneyValue(totalPrizes, currencySymbol)} + +
+); + +Prize.propTypes = { + totalPrizes: PT.number, + currencySymbol: PT.string, +}; + +export default Prize; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss new file mode 100644 index 000000000..467153013 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss @@ -0,0 +1,34 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.prize { + @include down($mfe-screen-xs) { + display: inline-flex; + flex-direction: row-reverse; + align-items: center; + + .value { + margin-right: 10px; + font-size: 21px; + line-height: 25px; + } + } +} + +.text, +.value { + display: block; +} + +.text { + font-size: $font-size-sm; + line-height: 22px; + color: #7F7F7F; +} + +.value { + @include barlow-semibold; + + font-size: 24px; + line-height: 26px; +} diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx new file mode 100644 index 000000000..16322b6f2 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx @@ -0,0 +1,70 @@ +import React, { useState, useMemo } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import Tag from "../../../../../components/Tag"; +import * as util from "../../../../../utils/tag"; +import { useTargetSize } from "../../../../../utils/hooks/useTargetSize"; + +import "./styles.scss"; + +const Tags = ({ tags, onClickTag, tooltip, isSelfService }) => { + const Tooltip = tooltip; + + const [size, ref] = useTargetSize(); + + const n = useMemo(() => { + const tagArray = [...tags]; + let tagsWidth = util.measureText(tagArray); + + if (!size) { + return 0; + } + + const maxWidth = Math.min(260, size.width); + if (tagsWidth < maxWidth) { + return tagArray.length; + } + + const widthOfMoreTag = 40; + while (tagsWidth > maxWidth - widthOfMoreTag) { + tagArray.pop(); + tagsWidth = util.measureText(tagArray); + } + + return tagArray.length; + }, [tags, size]); + + const more = n < tags.length ? tags.length - n : 0; + + const [collapsed, setCollapsed] = useState(more > 0); + const visibleTags = collapsed ? tags.slice(0, n) : tags; + const invisibleTags = collapsed ? tags.slice(n) : []; + + return ( +
+ {isSelfService && } + {visibleTags.map((tag) => ( + + ))} + {more > 0 && collapsed && ( + + setCollapsed(false)} /> + + )} +
+ ); +}; + +Tags.defaultProps = { + isSelfService: false, + tags: [], + tooltip: ({ children }) => <>{children}, +}; + +Tags.propTypes = { + isSelfService: PT.bool, + tags: PT.arrayOf(PT.string), + onClickTag: PT.func, +}; + +export default Tags; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/styles.scss new file mode 100644 index 000000000..7664d8599 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/Tags/styles.scss @@ -0,0 +1,27 @@ +@import "styles/variables"; + +.tags { + margin-top: -5px; + min-width: 1px; + + > * { + margin-top: 5px; + } + + > *:not(:last-child) { + margin-right: 5px; + } +} + +.tag { + display: inline-block; + padding: 3px 0.5em; + font-size: $font-size-xs; + line-height: 15px; + letter-spacing: 0.55px; + background: $white; + border: 1px solid $body-color; + border-radius: $border-radius-sm; + cursor: pointer; +} + diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/index.jsx new file mode 100644 index 000000000..e40e47e47 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/index.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import PT from "prop-types"; +import * as util from "../../../../../utils/icon"; + +import "./styles.scss"; + +const TrackIcon = ({ track, type, tcoEligible, onClick }) => ( + { + event.preventDefault(); + onClick(track); + }} + > + {util.createTrackIcon(track, type, tcoEligible)} + +); + +TrackIcon.propTypes = { + track: PT.string, + type: PT.string, + tcoEligible: PT.any, + onClick: PT.func, +}; + +export default TrackIcon; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss new file mode 100644 index 000000000..489052a54 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss @@ -0,0 +1,18 @@ +:global { + .track-icon { + position: relative; + display: inline-block; + width: 36px; + height: 36px; + vertical-align: middle; + line-height: 1; + cursor: pointer; + + > svg { + position: absolute; + top: 0; + left: 0; + display: block; + } + } +} diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/index.jsx b/src/earn/containers/Challenges/Listing/ChallengeItem/index.jsx new file mode 100644 index 000000000..714eada96 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/index.jsx @@ -0,0 +1,109 @@ +/* global process */ +import React from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import TrackIcon from "./TrackIcon"; +import NumRegistrants from "./NumRegistrants"; +import NumSubmissions from "./NumSubmissions"; +import Prize from "./Prize"; +import Tags from "./Tags"; +import PhaseEndDate from "./PhaseEndDate"; +import * as utils from "../../../../utils"; +import ProgressTooltip from "../tooltips/ProgressTooltip"; +import PlacementsTooltip from "../tooltips/PlacementsTooltip"; +import TagsMoreTooltip from "../tooltips/TagsMoreTooltip"; +import { CHALLENGES_URL } from "constants"; +import { Link } from "@reach/router"; + +import "./styles.scss"; + +const ChallengeItem = ({ challenge, onClickTag, onClickTrack, isLoggedIn }) => { + const totalPrizes = challenge.overview.totalPrizes; + const currencySymbol = utils.challenge.getCurrencySymbol(challenge.prizeSets); + const placementPrizes = utils.challenge.getPlacementPrizes( + challenge.prizeSets + ); + const checkpointPrizes = utils.challenge.getCheckpointPrizes( + challenge.prizeSets + ); + + let submissionLink = `${CHALLENGES_URL}/${challenge.id}`; + if (isLoggedIn && challenge.numOfSubmissions > 0) { + submissionLink += "?tab=submissions"; + } + + const challengeName = utils.toBreakableWords(challenge.name, (w) => + w.length > 20 ? `${w}` : w + ); + + return ( +
+
+ +
+
+
+
+ + + +
+ ( + + {children} + + )} + /> +
+
+ ( + + {children} + + )} + /> +
+
+ + + + + + +
+
+
+ + + + + +
+
+ ); +}; + +ChallengeItem.propTypes = { + challenge: PT.shape(), + onClickTag: PT.func, + onClickTrack: PT.func, + isLoggedIn: PT.bool, +}; + +export default ChallengeItem; diff --git a/src/earn/containers/Challenges/Listing/ChallengeItem/styles.scss b/src/earn/containers/Challenges/Listing/ChallengeItem/styles.scss new file mode 100644 index 000000000..cd1db6023 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/ChallengeItem/styles.scss @@ -0,0 +1,138 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.challenge-item { + display: flex; + padding: 16px 0 16px 16px; + border-top: 1px solid #E9E9E9; + + &:last-child { + border-bottom: 1px solid #E9E9E9; + } + + @include down($mfe-screen-sm) { + .info { + .name-container { + padding-right: 60px; + } + + .tags { + max-width: none; + min-width: 0; + max-width: 100%; + } + + .nums { + width: 0; + flex: none; + + > * { + &:first-child { + margin-left: 0; + display: inline-block; + width: 64px; + } + } + } + } + + .prize { + align-self: flex-start; + padding-top: 5px; + } + } + + @include down($mfe-screen-xs) { + position: relative; + flex-wrap: wrap; + padding: 20px 0 20px 68px; + + .track { + position: absolute; + left: 15px; + top: 20px; + } + + .info { + width: 100%; + + .name-container { + padding-right: 20px; + + .name { + line-height: 20px; + } + } + + .tags { + max-width: none; + min-width: 0; + } + + .name-container, + .tags { + margin-bottom: 10px; + } + + .nums { + display: none; + } + } + } +} + +.track { + flex: 0 0 54px; + + > div { + vertical-align: top; + } +} + +.info { + flex: 1 1 auto; + display: flex; + flex-wrap: wrap; + + .name-container { + flex: 1 1 100%; + margin-bottom: 18px; + + .name { + font-weight: bold; + font-size: inherit; + line-height: 26px; + } + } + + .tags { + max-width: calc(50% - 84px); + min-width: calc(50% - 84px); + flex: 1 1 auto; + + @media (min-width: ($screen-xxl + 1px)) { + min-width: 294px; + max-width: 25%; + } + } + + .nums { + flex: 0 0 104px; + white-space: nowrap; + + > * { + + &:first-child { + margin-left: 0; + display: inline-block; + width: 84px; + } + } + } +} + +.prize { + flex: 0 0 120px; + display: flex; + align-items: center; +} diff --git a/src/earn/containers/Challenges/Listing/errors/ChallengeError/index.jsx b/src/earn/containers/Challenges/Listing/errors/ChallengeError/index.jsx new file mode 100644 index 000000000..a9a2307d5 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/errors/ChallengeError/index.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import IconNotFound from "assets/icons/not-found.png"; + +import "./styles.scss"; + +const ChallengeError = () => ( +
+

+ not found +

+

+ No challenges were found. You can try changing your search parameters. +

+
+); + +export default ChallengeError; diff --git a/src/earn/containers/Challenges/Listing/errors/ChallengeError/styles.scss b/src/earn/containers/Challenges/Listing/errors/ChallengeError/styles.scss new file mode 100644 index 000000000..e2cedcc51 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/errors/ChallengeError/styles.scss @@ -0,0 +1,20 @@ +@import "styles/variables"; + +.challenge-error { + padding: 16px 24px; + min-height: 136px; + margin-bottom: 35px; + font-size: $font-size-sm; + line-height: 22px; + text-align: center; + background: $white; + border-radius: $border-radius-lg; + + h1 { + padding: 15px 0 10px; + } + + p { + margin-bottom: 20px; + } +} diff --git a/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/index.jsx b/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/index.jsx new file mode 100644 index 000000000..bc7fa3e1e --- /dev/null +++ b/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/index.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import IconNotFound from "assets/icons/not-found-recommended.png"; +import "./styles.scss"; + +const ChallengeRecommendedError = () => ( +
+

+ not found +

+

+ Looks like there are no Recommended Challenges that best + match your skills at this point. But you can try to join other challenges + that work for you. +

+
+); + +export default ChallengeRecommendedError; diff --git a/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/styles.scss b/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/styles.scss new file mode 100644 index 000000000..a1efc6864 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/errors/ChallengeRecommendedError/styles.scss @@ -0,0 +1,24 @@ +@import "styles/variables"; + +.challenge-recommended-error { + padding: 16px 24px; + min-height: 136px; + margin-bottom: 35px; + font-size: $font-size-sm; + line-height: 22px; + text-align: center; + background: $white; + border-radius: $border-radius-lg; + + h1 { + padding: 15px 0 10px; + } + + p { + margin-bottom: 20px; + } + + strong { + font-weight: bold; + } +} diff --git a/src/earn/containers/Challenges/Listing/index.jsx b/src/earn/containers/Challenges/Listing/index.jsx new file mode 100644 index 000000000..8c25e12db --- /dev/null +++ b/src/earn/containers/Challenges/Listing/index.jsx @@ -0,0 +1,219 @@ +import React, { useRef } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import moment from "moment"; +import Panel from "../../../components/Panel"; +import ChallengeError from "../Listing/errors/ChallengeError"; +import Pagination from "../../../components/Pagination"; +import ChallengeItem from "./ChallengeItem"; +import TextInput from "../../../components/TextInput"; +import Dropdown from "../../../components/Dropdown"; +import DateRangePicker from "../../../components/DateRangePicker"; +import ChallengeLoading from "../../../components/challenge-listing/ChallengeLoading"; +import * as utils from "../../../utils"; + +import * as constants from "../../../constants"; +import IconSearch from "../../../assets/icons/search.svg"; +import IconClear from "../../../assets/icons/close-gray.svg"; +import Button from "../../../components/Button"; + +import "./styles.scss"; + +const Listing = ({ + challenges, + loadingChallenges, + search, + page, + perPage, + sortBy, + total, + endDateStart, + startDateEnd, + updateFilter, + bucket, + sortByLabels, + isLoggedIn, + tags, +}) => { + const sortByOptions = utils.createDropdownOptions( + sortByLabels, + utils.getSortByLabel(constants.CHALLENGE_SORT_BY, sortBy) + ); + + const onSearch = useRef(_.debounce((f) => f(), 1000)); + const onChangeSortBy = (newSortByOptions) => { + const selectedOption = utils.getSelectedDropdownOption(newSortByOptions); + const filterChange = { + sortBy: constants.CHALLENGE_SORT_BY[selectedOption.label], + page: 1, + }; + updateFilter(filterChange); + }; + + const onShowSidebar = () => { + const sidebarEl = document.getElementById("sidebar-id"); + sidebarEl.classList.add("show"); + }; + + return ( + + +
+
+ + + + { + onSearch.current(() => { + const filterChange = { + search: value, + page: 1, + }; + updateFilter(filterChange); + }); + }} + maxLength="100" + /> + {search.length ? ( + + { + onSearch.current(() => { + const filterChange = { + search: "", + page: 1, + }; + updateFilter(filterChange); + }); + }} + /> + + ) : ( + + )} +
+
+
+ +
+
+ { + const d = range.endDate + ? moment(range.endDate).toISOString() + : null; + const s = range.startDate + ? moment(range.startDate).toISOString() + : null; + const filterChange = { + endDateStart: s, + startDateEnd: d, + page: 1, + }; + updateFilter(filterChange); + }} + range={{ + startDate: endDateStart ? moment(endDateStart).toDate() : null, + endDate: startDateEnd ? moment(startDateEnd).toDate() : null, + }} + /> +
+
+ +
+
+ + {loadingChallenges && _.times(3, () => )} + {!loadingChallenges && + (challenges.length ? ( + + {challenges.map((challenge, index) => ( +
+ { + const filterChange = { + tags: [tag], + page: 1, + }; + updateFilter(filterChange); + }} + onClickTrack={(track) => { + const filterChange = { + tracks: [track], + page: 1, + }; + updateFilter(filterChange); + }} + isLoggedIn={isLoggedIn} + /> +
+ ))} +
+ ) : ( + + ))} + +
+ { + const filterChange = { + page: utils.pagination.pageIndexToPage(event.pageIndex), + perPage: event.pageSize, + }; + updateFilter(filterChange); + }} + /> +
+
+ + ); +}; + +Listing.propTypes = { + challenges: PT.arrayOf(PT.shape()), + loadingChallenges: PT.bool, + search: PT.string, + page: PT.number, + perPage: PT.number, + sortBy: PT.string, + total: PT.number, + endDateStart: PT.string, + startDateEnd: PT.string, + updateFilter: PT.func, + bucket: PT.string, + sortByLabels: PT.arrayOf(PT.string), + isLoggedIn: PT.bool, +}; + +export default Listing; diff --git a/src/earn/containers/Challenges/Listing/styles.scss b/src/earn/containers/Challenges/Listing/styles.scss new file mode 100644 index 000000000..57f6fa857 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/styles.scss @@ -0,0 +1,133 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.even { + background: #FBFBFB; +} + +.odd { + background: transparent; +} + +.header-container { + display: flex; + align-items: flex-start; + + @include down($mfe-screen-xs) { + flex-wrap: wrap; + + .separator { + display: none; + } + + .input-group { + width: 100%; + max-width: none; + flex: auto; + margin: 0 0 20px 0; + } + + .sort-by { + max-width: 241px; + } + + .sort-by, + .from-to { + flex: 1 1 auto; + margin: 0 22px 0 0; + + @include down(375px - 1px) { + max-width: calc(100% - 92px); + } + } + + .filter-button { + display: block; + margin: 0 0 0 auto; + + button { + height: 40px; + border-radius: 20px; + } + } + } +} + +.input-group { + position: relative; + display: inline-block; + margin-right: 16px; + margin-bottom: 17px; + flex: auto; + max-width: 380px; + + .search-icon { + position: absolute; + bottom: 0; + left: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 40px; + } + + .clear-icon { + position: absolute; + bottom: 0; + right: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 40px; + cursor: pointer; + } + + input { + margin-top: 0 !important; + padding-left: 46px !important; + } +} + +.separator { + display: inline-block; + width: 1px; + height: 36px; + line-height: 40px; + vertical-align: middle; + background: #E9E9E9; +} + +.sort-by { + display: inline-block; + width: 200px; +} + +.from-to { + width: 50%; + max-width: 241px; +} + +.sort-by, +.from-to { + margin-left: 16px; + + &.hidden { + display: none; + } +} + +.filter-button { + display: none; +} + +.pagination { + padding: 17px 0 15px; + + @include down($mfe-screen-xs) { + padding: 20px; + } +} diff --git a/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/index.jsx b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/index.jsx new file mode 100644 index 000000000..3e1b8582b --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/index.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import PT from "prop-types"; +import * as utils from "../../../../../../utils"; + +import "./styles.scss"; + +const Prize = ({ place, prize, currencySymbol }) => ( +
3 ? "nth" : place}`}> + + {place} + + + {utils.formatMoneyValue(prize, currencySymbol)} + +
+); + +Prize.defaultProps = { + place: 1, +}; + +Prize.propTypes = {}; + +export default Prize; diff --git a/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/styles.scss b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/styles.scss new file mode 100644 index 000000000..df9e549e4 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/Prize/styles.scss @@ -0,0 +1,52 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.medal { + &.place-1 { + color: $tc-gold; + } + + &.place-2 { + color: $tc-silver; + } + + &.place-3 { + color: $tc-bronze; + } + + &.place-nth { + color: $tc-dark-blue-30; + } +} + +.prize { + min-width: 24px; + margin-right: 24px; + padding-bottom: 10px; + box-shadow: inset 0 -3px currentColor; + + .value, + .placement { + display: block; + } + + .placement { + @include roboto-bold; + + font-size: 10px; + line-height: 14px; + letter-spacing: 0.42px; + + &::after { + content: attr(data-placement); + } + } + + .value { + @include barlow-semibold; + + font-size: 20px; + line-height: 24px; + color: $white; + } +} diff --git a/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/index.jsx b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/index.jsx new file mode 100644 index 000000000..e3ebf7722 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/index.jsx @@ -0,0 +1,57 @@ +import React from "react"; +import PT from "prop-types"; +import Tooltip from "../../../../../components/Tooltip"; +import Prize from "./Prize"; +import * as utils from "../../../../../utils"; + +import "./styles.scss"; + +const PlacementsTooltip = ({ + children, + prizes, + checkpointPrizes, + currencySymbol, + placement, +}) => { + let numberOfCheckpointsPrizes; + let topCheckPointPrize; + if (checkpointPrizes && checkpointPrizes.length) { + numberOfCheckpointsPrizes = checkpointPrizes.length; + topCheckPointPrize = checkpointPrizes[0]; + } + + const Content = () => ( +
+
    + {prizes.map((prize, index) => ( +
  • + +
  • + ))} +
+ {checkpointPrizes && checkpointPrizes.length > 0 && ( +

+ {numberOfCheckpointsPrizes} checkpoints awarded worth{" "} + + {utils.formatMoneyValue(topCheckPointPrize, currencySymbol)} + {" "} + each +

+ )} +
+ ); + + return ( + }> + {children} + + ); +}; + +PlacementsTooltip.propTypes = {}; + +export default PlacementsTooltip; diff --git a/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/styles.scss b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/styles.scss new file mode 100644 index 000000000..ef89f1a0c --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/PlacementsTooltip/styles.scss @@ -0,0 +1,23 @@ +@import "styles/variables"; +@import "styles/mixins"; + +$prize-space-10: $base-unit * 2; + +.prizes-tooltip { + max-width: 480px; + padding: 15px; + overflow: hidden; + + .placements { + display: flex; + } + + .checkpoint-message { + margin-top: 14px; + font-weight: bold; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.25px; + color: $white; + } +} diff --git a/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/index.jsx b/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/index.jsx new file mode 100644 index 000000000..67257b316 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/index.jsx @@ -0,0 +1,142 @@ +import React from "react"; +import PT from "prop-types"; +import Tooltip from "../../../../../components/Tooltip"; +import * as util from "../../../../../utils/challenge"; +import _ from "lodash"; +import moment from "moment"; + +import "./styles.scss"; + +const ProgressTooltip = ({ children, challenge, placement }) => { + const Phase = ({ date, last, phase, progress, started }) => { + const limitProgress = parseFloat(_.replace(progress, "%", "")); + const limitWidth = limitProgress <= 100 ? limitProgress : 100; + + return ( +
+
{phase}
+
+
+
+
+
{`${getDate(date)}, ${getTime(date)}`}
+
+ ); + }; + + const Content = ({ c }) => { + let steps = []; + + const allPhases = c.phases || []; + const endPhaseDate = Math.max( + ...allPhases.map((p) => util.phaseEndDate(p)) + ); + const registrationPhase = + allPhases.find((phase) => phase.name === "Registration") || {}; + const submissionPhase = + allPhases.find((phase) => phase.name === "Submission") || {}; + const checkpointPhase = + allPhases.find((phase) => phase.name === "Checkpoint Submission") || {}; + + if (!_.isEmpty(registrationPhase)) { + steps.push({ + date: util.phaseStartDate(registrationPhase), + name: "Start", + }); + } + if (!_.isEmpty(checkpointPhase)) { + steps.push({ + date: util.phaseEndDate(checkpointPhase), + name: "Checkpoint", + }); + } + const iterativeReviewPhase = allPhases.find( + (phase) => phase.isOpen && phase.name === "Iterative Review" + ); + if (iterativeReviewPhase) { + steps.push({ + date: util.phaseEndDate(iterativeReviewPhase), + name: "Iterative Review", + }); + } else if (!_.isEmpty(submissionPhase)) { + steps.push({ + date: util.phaseEndDate(submissionPhase), + name: "Submission", + }); + } + steps.push({ + date: new Date(endPhaseDate), + name: "End", + }); + + steps = steps.sort((a, b) => a.date.getTime() - b.date.getTime()); + const currentPhaseEnd = new Date(); + steps = steps.map((step, index) => { + let progress = 0; + if (index < steps.length - 1) { + if (steps[1 + index].date.getTime() < currentPhaseEnd.getTime()) + progress = 100; + else if (step.date.getTime() > currentPhaseEnd.getTime()) progress = 0; + else { + const left = currentPhaseEnd.getTime() - step.date.getTime(); + if (left < 0) progress = -1; + else { + progress = + 100 * + (left / + (steps[1 + index].date.getTime() - + steps[index].date.getTime())); + } + } + } + + const phaseId = index; + return ( + + ); + }); + + return ( +
+
{steps}
+
+ ); + }; + + return ( + }> + {children} + + ); +}; + +ProgressTooltip.defaultProps = { + placement: "bottom", +}; + +ProgressTooltip.propTypes = {}; + +export default ProgressTooltip; + +function getDate(date) { + return moment(date).format("MMM DD"); +} + +function getTime(date) { + const duration = moment(date); + const hour = duration.hours(); + const hString = hour < 10 ? `0${hour}` : hour; + const min = duration.minutes(); + const mString = min < 10 ? `0${min}` : min; + const res = `${hString}:${mString}`; + return res[1] === "-" ? "Late" : `${res}`; +} diff --git a/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/styles.scss b/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/styles.scss new file mode 100644 index 000000000..6db12af36 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/ProgressTooltip/styles.scss @@ -0,0 +1,89 @@ +@import 'styles/variables'; +@import 'styles/mixins'; + +$corner-radius: 2px; +$tip-offset: 80px; +$tip-space-10: $base-unit * 2; +$tip-space-15: $base-unit * 3; +$tip-space-35: $base-unit * 7; +$tip-space-95: $base-unit * 19; +$tip-radius-15: $corner-radius * 7 + 1; +$tip-radius-4: $corner-radius * 2; + +div.progress-bar-tooltip { + @include roboto-regular; + + color: $tc-white; + max-width: none; + padding: 0 $base-unit * 3; + + .rc-tooltip-inner { + padding: 0 $base-unit * 3; + } + + .tip { + word-wrap: none; + white-space: nowrap; + + .phase { + display: inline-block; + line-height: $tip-space-15; + min-width: $tip-offset; + padding: 18px 0; + width: $tip-space-95; + + &:last-child { + border-radius: 0 $tip-radius-4 $tip-radius-4 0; + padding-right: $tip-space-15; + } + } + } + + .bar { + background: $tc-gray-70; + height: 8px; + margin: ($tip-space-10) - 3 0; + width: 100%; + + &.last { + width: 0; + } + + .inner-bar { + background: $lightGreen; + position: relative; + border-radius: $corner-radius * 4; + border: 1px solid $tc-gray-90; + top: -17px; + height: $tip-space-10 - 1; + z-index: 1; + } + + .point { + background: $tc-gray-20; + border: 2px solid $tc-gray-90; + border-radius: $corner-radius * 9; + height: 16px; + left: -($base-unit - 2); + position: relative; + top: -4px; + width: 16px; + z-index: 2; + } + + &.started { + .point { + background: $lightGreen; + } + } + } + + .date { + font-weight: 400; + font-size: 12px; + color: $tc-gray-30; + letter-spacing: 0; + line-height: $tip-space-15; + text-shadow: none; + } +} diff --git a/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/index.jsx b/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/index.jsx new file mode 100644 index 000000000..e7df0e8c8 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/index.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import PT from "prop-types"; +import Tooltip from "../../../../../components/Tooltip"; +import Tag from "../../../../../components/Tag"; + +import "./styles.scss"; + +const TagsMoreTooltip = ({ children, tags, onClickTag, placement }) => { + const Content = () => { + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); + }; + + return ( + } + trigger={["hover", "focus"]} + > + {children} + + ); +}; + +TagsMoreTooltip.defaultProps = { + placement: "bottom", +}; + +TagsMoreTooltip.propTypes = {}; + +export default TagsMoreTooltip; diff --git a/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/styles.scss b/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/styles.scss new file mode 100644 index 000000000..afe51e852 --- /dev/null +++ b/src/earn/containers/Challenges/Listing/tooltips/TagsMoreTooltip/styles.scss @@ -0,0 +1,17 @@ +@import "styles/variables"; + +.tags-more { + padding: 10px 6px 6px 10px; + max-width: 206px; + + > * { + margin-right: 4px; + margin-bottom: 4px; + padding: 6px 4px; + font-size: 11px; + line-height: 15px; + letter-spacing: 0.5px; + color: $white; + background: $tc-gray-70; + } +} diff --git a/src/earn/containers/Challenges/index.jsx b/src/earn/containers/Challenges/index.jsx new file mode 100644 index 000000000..4470ebbc8 --- /dev/null +++ b/src/earn/containers/Challenges/index.jsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from "react"; +import PT from "prop-types"; +import { connect } from "react-redux"; +import Listing from "./Listing"; +import actions from "../../actions"; +// import ChallengeRecommendedError from "./Listing/errors/ChallengeRecommendedError"; +import * as constants from "../../constants"; +// import IconListView from "../../assets/icons/list-view.svg"; +// import IconCardView from "../../assets/icons/card-view.svg"; +import { Banner } from "@topcoder/micro-frontends-earn-app"; + +import * as utils from "../../utils"; +import { useMediaQuery } from "react-responsive"; +import { useCssVariable } from "../../utils/hooks/useCssVariable"; +import IconArrow from "../../assets/icons/arrow.svg"; + +import "./styles.scss"; +import { useClickOutside } from "../../utils/hooks/useClickOutside"; +import { LoadingSpinner } from "../../../../src-ts/lib"; + +const Challenges = ({ + challenges, + challengesMeta, + search, + page, + perPage, + sortBy, + total, + endDateStart, + startDateEnd, + updateFilter, + bucket, + recommended, + recommendedChallenges, + initialized, + updateQuery, + tags, + loadingChallenges, +}) => { + const [isLoggedIn, setIsLoggedIn] = useState(null); + + useEffect(() => { + const checkIsLoggedIn = async () => { + setIsLoggedIn(await utils.auth.isLoggedIn()); + }; + checkIsLoggedIn(); + }, []); + + // reset pagination + if ( + page > 1 && + challengesMeta.total && + challengesMeta.total > 0 && + challenges.length === 0 + ) { + updateFilter({ + page: 1, + }); + updateQuery({ + page: 1, + }); + } + + const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; + const isRecommended = recommended && bucket === BUCKET_OPEN_FOR_REGISTRATION; + const sortByValue = isRecommended + ? sortBy + : sortBy === constants.CHALLENGE_SORT_BY_RECOMMENDED + ? constants.CHALLENGE_SORT_BY_MOST_RECENT + : sortBy; + const sortByLabels = isRecommended + ? Object.keys(constants.CHALLENGE_SORT_BY) + : Object.keys(constants.CHALLENGE_SORT_BY).filter( + (label) => label !== constants.CHALLENGE_SORT_BY_RECOMMENDED_LABEL + ); + const noRecommendedChallenges = + bucket === BUCKET_OPEN_FOR_REGISTRATION && + recommended && + recommendedChallenges.length === 0; + + const screenXs = useCssVariable("--mfe-screen-xs", (value) => + parseInt(value) + ); + const isScreenXs = useMediaQuery({ maxWidth: screenXs }); + + const [menuExpanded, setMenuExpanded] = useState(false); + const menuRef = useClickOutside(menuExpanded, (event) => { + setMenuExpanded(false); + }); + + const mobileMenu = ( +