-
-
-
- {formatMessage(messages['recommendation.page.heading'])}
-
-
-
-
-
-
-
-
-
-
-
-
+ {isExtraSmall ? (
+
+ ) : (
+
+ )}
+
+ {isLoading && (
+
+ )}
+ {!isLoading && (
+
+ )}
-
+
>
);
diff --git a/src/recommendations/RecommendationsPageLayouts/LargeLayout.jsx b/src/recommendations/RecommendationsPageLayouts/LargeLayout.jsx
new file mode 100644
index 000000000..559e4119e
--- /dev/null
+++ b/src/recommendations/RecommendationsPageLayouts/LargeLayout.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Skeleton } from '@edx/paragon';
+import PropTypes from 'prop-types';
+
+import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
+import messages from '../messages';
+import RecommendationsList from '../RecommendationsList';
+
+const RecommendationsLargeLayout = (props) => {
+ const {
+ userId,
+ isLoading,
+ personalizedRecommendations,
+ trendingProducts,
+ popularProducts,
+ } = props;
+ const { formatMessage } = useIntl();
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+ {formatMessage(messages['recommendation.page.heading'])}
+
+ {personalizedRecommendations.length > 0 && (
+ <>
+
+ {formatMessage(messages['recommendation.option.recommended.for.you'])}
+
+
+ >
+ )}
+
+ {formatMessage(messages['recommendation.option.popular'])}
+
+
+
+ {formatMessage(messages['recommendation.option.trending'])}
+
+
+ >
+ );
+};
+
+RecommendationsLargeLayout.propTypes = {
+ userId: PropTypes.number.isRequired,
+ isLoading: PropTypes.bool,
+ personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
+ trendingProducts: PropTypes.arrayOf(PropTypes.shape({})),
+ popularProducts: PropTypes.arrayOf(PropTypes.shape({})),
+};
+
+RecommendationsLargeLayout.defaultProps = {
+ isLoading: true,
+ personalizedRecommendations: [],
+ trendingProducts: [],
+ popularProducts: [],
+};
+
+export default RecommendationsLargeLayout;
diff --git a/src/recommendations/RecommendationsPageLayouts/SmallLayout.jsx b/src/recommendations/RecommendationsPageLayouts/SmallLayout.jsx
new file mode 100644
index 000000000..9bc71a23b
--- /dev/null
+++ b/src/recommendations/RecommendationsPageLayouts/SmallLayout.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Collapsible, Skeleton } from '@edx/paragon';
+import PropTypes from 'prop-types';
+
+import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
+import messages from '../messages';
+import RecommendationsList from '../RecommendationsList';
+
+const RecommendationsSmallLayout = (props) => {
+ const {
+ userId,
+ isLoading,
+ personalizedRecommendations,
+ trendingProducts,
+ popularProducts,
+ } = props;
+ const { formatMessage } = useIntl();
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+ {formatMessage(messages['recommendation.page.heading'])}
+
+ {personalizedRecommendations.length > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+RecommendationsSmallLayout.propTypes = {
+ userId: PropTypes.number.isRequired,
+ isLoading: PropTypes.bool,
+ personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
+ trendingProducts: PropTypes.arrayOf(PropTypes.shape({})),
+ popularProducts: PropTypes.arrayOf(PropTypes.shape({})),
+};
+
+RecommendationsSmallLayout.defaultProps = {
+ isLoading: true,
+ personalizedRecommendations: [],
+ trendingProducts: [],
+ popularProducts: [],
+};
+
+export default RecommendationsSmallLayout;
diff --git a/src/recommendations/data/loadingCoursesPlaceholders.js b/src/recommendations/data/loadingCoursesPlaceholders.js
new file mode 100644
index 000000000..0a0c338b0
--- /dev/null
+++ b/src/recommendations/data/loadingCoursesPlaceholders.js
@@ -0,0 +1,36 @@
+const placeholderCourse = {
+ activeCourseRun: {
+ key: 'course',
+ marketingUrl: '/',
+ type: 'Verified and Audit',
+ },
+ cardType: 'course',
+ image: {
+ src: './',
+ },
+ inProspectus: true,
+ objectID: 'skeleton',
+ owners: [{
+ key: 'skeleton',
+ logoImageUrl: './',
+ name: 'skeleton',
+ }],
+ position: 0,
+ prospectusPath: './',
+ queryID: 'skeleton',
+ recentEnrollmentCount: 0,
+ title: 'skeleton',
+ topics: [{
+ topic: 'skeleton',
+ }],
+ uuid: 'skeleton',
+};
+
+const loadingCoursesPlaceHolders = [
+ { ...placeholderCourse, uuid: '294ea4rtn2117', courseType: 'course' },
+ { ...placeholderCourse, uuid: '294fga4681117', courseType: 'course' },
+ { ...placeholderCourse, uuid: '294ea4278e117', courseType: 'course' },
+ { ...placeholderCourse, uuid: '294eax2rtg117', courseType: 'course' },
+];
+
+export default loadingCoursesPlaceHolders;
diff --git a/src/recommendations/messages.js b/src/recommendations/messages.js
index 41e7ff46c..cdba67538 100644
--- a/src/recommendations/messages.js
+++ b/src/recommendations/messages.js
@@ -18,7 +18,7 @@ const messages = defineMessages({
},
'recommendation.option.trending': {
id: 'recommendation.option.trending',
- defaultMessage: 'Trending',
+ defaultMessage: 'Trending Now',
description: 'Title for trending products',
},
'recommendation.option.popular': {
@@ -26,6 +26,11 @@ const messages = defineMessages({
defaultMessage: 'Most Popular',
description: 'Title for popular products',
},
+ 'recommendation.option.recommended.for.you': {
+ id: 'recommendation.option.recommended.for.you',
+ defaultMessage: 'Recommended for you',
+ description: 'Title for personalized products',
+ },
});
export const cardBadgesMessages = defineMessages({
diff --git a/src/recommendations/tests/RecommendationsPage.test.jsx b/src/recommendations/tests/RecommendationsPage.test.jsx
index f30b28796..45355513b 100644
--- a/src/recommendations/tests/RecommendationsPage.test.jsx
+++ b/src/recommendations/tests/RecommendationsPage.test.jsx
@@ -3,10 +3,13 @@ import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
+import { useMediaQuery } from '@edx/paragon';
import { mount } from 'enzyme';
import configureStore from 'redux-mock-store';
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
+import useRecommendations from '../data/hooks/useRecommendations';
+import mockedRecommendedProducts from '../data/tests/mockedData';
import RecommendationsPage from '../RecommendationsPage';
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
@@ -16,6 +19,13 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
+jest.mock('@edx/paragon', () => ({
+ ...jest.requireActual('@edx/paragon'),
+ useMediaQuery: jest.fn(),
+}));
+
+jest.mock('../data/hooks/useRecommendations', () => jest.fn());
+
describe('RecommendationsPageTests', () => {
mergeConfig({
GENERAL_RECOMMENDATIONS: '[]',
@@ -55,12 +65,25 @@ describe('RecommendationsPageTests', () => {
};
});
+ useRecommendations.mockReturnValue({
+ algoliaRecommendations: mockedRecommendedProducts,
+ popularProducts: mockedRecommendedProducts,
+ trendingProducts: mockedRecommendedProducts,
+ isLoading: false,
+ });
+
it('should redirect to dashboard if user is not coming from registration workflow', () => {
mount(reduxWrapper(
));
expect(window.location.href).toEqual(dashboardUrl);
});
it('should redirect if either popular or trending recommendations are not configured', () => {
+ useRecommendations.mockReturnValueOnce({
+ algoliaRecommendations: mockedRecommendedProducts,
+ popularProducts: [],
+ trendingProducts: mockedRecommendedProducts,
+ isLoading: false,
+ });
mount(reduxWrapper(
));
expect(window.location.href).toEqual(redirectUrl);
});
@@ -72,8 +95,58 @@ describe('RecommendationsPageTests', () => {
expect(window.location.href).toEqual(redirectUrl);
});
- it('displays popular products as default recommendations', () => {
+ it('should display recommendations small layout (collapsibles) for small screen', () => {
+ useMediaQuery.mockReturnValue(true);
+ const recommendationsPage = mount(reduxWrapper(
));
+
+ expect(recommendationsPage.find('.pgn_collapsible').exists()).toBeTruthy();
+ expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
+ });
+
+ it('should display recommendations large layout for large screen', () => {
+ useMediaQuery.mockReturnValue(false);
+ const recommendationsPage = mount(reduxWrapper(
));
+
+ expect(recommendationsPage.find('.pgn_collapsible').exists()).toBeFalsy();
+ expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
+ });
+
+ it('should display skeletons if recommendations are loading for large screen', () => {
+ useMediaQuery.mockReturnValue(false);
+ useRecommendations.mockReturnValueOnce({
+ algoliaRecommendations: [],
+ popularProducts: [],
+ trendingProducts: [],
+ isLoading: true,
+ });
+ const recommendationsPage = mount(reduxWrapper(
));
+
+ expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
+ });
+
+ it('should display skeletons if recommendations are loading for small screen', () => {
+ useMediaQuery.mockReturnValue(true);
+ useRecommendations.mockReturnValueOnce({
+ algoliaRecommendations: [],
+ popularProducts: [],
+ trendingProducts: [],
+ isLoading: true,
+ });
const recommendationsPage = mount(reduxWrapper(
));
- expect(recommendationsPage.find('.nav-link .active a').text()).toEqual('Most Popular');
+
+ expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
+ });
+
+ it('should display only trending and popular recs if there are no algolia recommendations', () => {
+ useMediaQuery.mockReturnValue(false);
+ useRecommendations.mockReturnValueOnce({
+ algoliaRecommendations: [],
+ popularProducts: mockedRecommendedProducts,
+ trendingProducts: mockedRecommendedProducts,
+ isLoading: false,
+ });
+ const recommendationsPage = mount(reduxWrapper(
));
+
+ expect(recommendationsPage.find('.recommendations-container__card-list').length).toEqual(2);
});
});
diff --git a/src/sass/_recommendations_page.scss b/src/sass/_recommendations_page.scss
index 57af1f54b..c59849419 100644
--- a/src/sass/_recommendations_page.scss
+++ b/src/sass/_recommendations_page.scss
@@ -1,78 +1,69 @@
-.nav-tabs {
- border-bottom: 2px solid transparent;
-}
+$card-gap: 24px;
.recommendations-container__card-list {
- padding-left: 0.0625rem;
- padding-bottom: 0.125rem;
+ gap: $card-gap $card-gap;
- @include media-breakpoint-down(xl) {
- overflow-x: scroll;
- overflow-y: hidden;
+ @include media-breakpoint-down(sm) {
+ margin-bottom: 0 !important;
}
-}
-.recommendations-container__heading {
- overflow-wrap: break-word;
-}
-.recommendations-container {
- padding: 0 1rem;
- margin: 0 0 1.875rem 0;
+ .recommendation-card {
+ flex: 0 1 100%;
- @include media-breakpoint-up(lg) {
- max-width: $max-width-sm + 5 * $grid-gutter-width !important;
- }
+ @include media-breakpoint-up(sm) {
+ flex: 0 1 calc(50% - #{$card-gap - 12});
+ }
- @include media-breakpoint-up(xl) {
- max-width: $max-width-md + $grid-gutter-width !important;
- }
+ @include media-breakpoint-up(md) {
+ flex: 0 1 calc(33.333% - #{$card-gap - 8});
+ }
- @include media-breakpoint-up(xxl) {
- max-width: $max-width-lg + $grid-gutter-width !important;
+ @include media-breakpoint-up(lg) {
+ flex: 0 1 calc(25% - #{$card-gap - 6});
+ }
}
}
-.recommendation-card {
- cursor: pointer;
- .pgn__hyperlink {
- display: block;
- }
-
- .pgn__card {
- width: 281px;
- height: 332px;
- margin: 0 !important;
- }
+.recommendations-container__heading {
+ overflow-wrap: break-word;
+}
- .pgn__card-image-cap {
- height: 6.5rem;
- object-position: top;
- }
+#course-recommendations .collapsible-trigger {
+ justify-content: space-between;
+ text-decoration: none;
+ border: 1px solid var(--light-700, #D7D3D1);
+ background: var(--extras-white, #FFF);
+ font-weight: 700;
+ line-height: 24px;
+}
- .pgn__card-header-title-md {
- font-weight: 700;
- font-size: 1.125rem;
- line-height: 1.5rem;
- }
+.pgn_collapsible {
+ padding-bottom: 16px;
+}
- .pgn__card-header-subtitle-md{
- font-weight: 400;
- font-size:0.875rem;
- line-height: 1.5rem;
- color: $gray-700;
- }
- .pgn__card-footer {
- bottom: 0;
- position: absolute;
- padding-bottom: 1rem !important;
- }
- .pgn__card__footer-text{
- font-weight: 400;
- font-size: 0.75rem;
- line-height: 1.25rem;
- }
+#course-recommendations .collapsible-body {
+ padding: 16px 0 0 0;
}
+
.footer-icon{
height: 16px;
width: 16px;
}
+
+.recommendations-heading__skeleton {
+ width: 300px;
+ height: 32px;
+ margin-bottom: 52px;
+}
+
+.recommendations-subheading__skeleton {
+ width: 300px;
+ height: 22px;
+ margin-bottom: 16px;
+}
+
+.skip-btn__skeleton {
+ width: 139px;
+ height: 40px;
+}
+