feat: design change for recommendations (#997)

Description:

Design change for recommendations in which covered all the card type

VAN-1564
This commit is contained in:
Blue
2023-07-26 10:49:47 +05:00
committed by GitHub
parent f4e89efdb4
commit cb380a2031
16 changed files with 1095 additions and 287 deletions

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Badge, Card, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import { truncateText } from '../../data/utils';
const BaseCard = ({
url,
customHeaderImage,
schoolLogo,
title,
uuid,
subtitle,
variant,
productTypeCopy,
footer,
handleOnClick,
isLoading = false,
}) => (
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
<Hyperlink
destination={url}
target="_blank"
className="card-box"
showLaunchIcon={false}
onClick={handleOnClick}
>
<Card
className={`base-card ${variant}`}
variant={variant}
onClick={handleOnClick}
isLoading={isLoading}
>
<Card.ImageCap
className="base-card-image-show optanon-category-C0001"
src={customHeaderImage}
srcAlt={`header image for ${subtitle}`}
logoSrc={schoolLogo}
logoAlt={`logo for ${subtitle}`}
imageLoadingType="lazy"
/>
<Card.Header
className="mt-2"
title={truncateText(title)}
subtitle={truncateText(subtitle)}
/>
<Card.Section className="d-flex">
<div className="product-badge">
<Badge>
{productTypeCopy}
</Badge>
</div>
<div className="footer-content mt-2">
{footer}
</div>
</Card.Section>
</Card>
</Hyperlink>
</div>
);
BaseCard.propTypes = {
title: PropTypes.string.isRequired,
uuid: PropTypes.string.isRequired,
footer: PropTypes.element.isRequired,
productTypeCopy: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
variant: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
customHeaderImage: PropTypes.string.isRequired,
schoolLogo: PropTypes.string.isRequired,
isLoading: PropTypes.bool,
handleOnClick: PropTypes.func.isRequired,
};
BaseCard.defaultProps = {
isLoading: false,
};
export default BaseCard;

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { Launch } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { isEdxProgram } from '../../data/utils';
import {
cardFooterMessages,
externalLinkIconMessages,
} from '../../messages';
const ProductCardFooter = ({
factoid,
quickFacts,
courseLength,
footerMessage,
cardType,
is2UDegreeProgram,
isSubscriptionView,
}) => {
const intl = useIntl();
const courseLengthLabel = courseLength > 1 ? 'Courses' : 'Course';
if (isSubscriptionView) {
return (
<p className="d-inline-block x-small">
{intl.formatMessage(
cardFooterMessages[
'recommendation.2u-product-card.footer-text.number-of-courses'
],
{ length: courseLength, label: courseLengthLabel },
)}
<span className="p-2"></span>
<p className="d-inline-block">
{intl.formatMessage(
cardFooterMessages[
'recommendation.2u-product-card.footer-text.subscription'
],
)}
</p>
</p>
);
}
if (footerMessage) {
return (
<div className="footer-message d-flex align-items-center">
<p className="x-small">{footerMessage}</p>
<Icon
src={Launch}
className="ml-1 footer-icon"
screenReaderText={intl.formatMessage(
externalLinkIconMessages[
'recommendation.2u-product-card.launch-icon.sr-text'
],
)}
/>
</div>
);
}
if (courseLength) {
return (
<p className="x-small">
{intl.formatMessage(
cardFooterMessages[
'recommendation.2u-product-card.footer-text.number-of-courses'
],
{ length: courseLength, label: courseLengthLabel },
)}
</p>
);
}
if (isEdxProgram({ cardType, is2UDegreeProgram })) {
if (quickFacts && quickFacts.length > 0) {
const quickFactsCount = quickFacts.length;
const threeFactsArrangement = [1, 3, 0];
const twoFactsArrangement = [0, 2];
return (
<>
{(quickFactsCount > 3 ? threeFactsArrangement : twoFactsArrangement)
.map((index) => quickFacts[index])
.filter(Boolean)
.map((fact, idx) => (
<p key={fact.text} className="d-inline-block x-small">
{idx > 0 && <span className="p-2"></span>}
{fact && fact.text}
</p>
))}
</>
);
}
}
if (factoid) {
return <p className="x-small">{factoid}</p>;
}
return null;
};
ProductCardFooter.propTypes = {
cardType: PropTypes.string,
factoid: PropTypes.string,
footerMessage: PropTypes.string,
quickFacts: PropTypes.arrayOf(PropTypes.shape({})),
courseLength: PropTypes.number,
is2UDegreeProgram: PropTypes.bool,
isSubscriptionView: PropTypes.bool,
};
ProductCardFooter.defaultProps = {
cardType: '',
factoid: '',
footerMessage: '',
quickFacts: [],
courseLength: undefined,
is2UDegreeProgram: false,
isSubscriptionView: false,
};
export default ProductCardFooter;

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import BaseCard from './BaseCard';
import Footer from './Footer';
import { EXTERNAL_PRODUCT_SOURCES } from '../data/constants';
import { createCodeFriendlyProduct, getVariant, useProductType } from '../data/utils';
import {
cardBadgesMessages,
cardFooterMessages,
} from '../messages';
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
import { trackRecommendationsClicked } from '../track';
const ProductCard = ({
product,
userId,
position,
}) => {
const { formatMessage } = useIntl();
const productType = useProductType(product?.courseType, product?.type);
const variant = getVariant(productType);
const headerImage = product?.cardImageUrl || product?.image?.src;
const footerMessagesObj = {
[EXTERNAL_PRODUCT_SOURCES.EMERITUS]: formatMessage(
cardFooterMessages['recommendation.2u-product-card.footer-text.emeritus'],
),
[EXTERNAL_PRODUCT_SOURCES.SHORELIGHT]: formatMessage(
cardFooterMessages['recommendation.2u-product-card.footer-text.shorelight'],
),
};
const schoolName = product?.organizationShortCodeOverride
|| product?.owners?.[0]?.name
|| product?.authoringOrganizations?.[0]?.name
|| product?.partner;
const schoolLogo = product?.organizationLogoOverrideUrl
|| product?.logoFilename
|| product?.authoringOrganizations?.[0]?.logoImageUrl
|| product?.owners?.[0]?.logoImageUrl;
const { owners } = product;
const multipleSchoolNames = [];
const isMultipleOwner = owners?.length > 1;
if ((owners?.length > 1)) {
owners.forEach((owner, index, arr) => {
let school;
if (index === arr.length - 1) {
school = (
<span key={owner.name}>{owner.name}</span>
);
} else {
school = (
<>
<span key={owner.name}>{owner.name}</span>
<br />
</>
);
}
multipleSchoolNames.push(school);
});
}
const productTypeCopy = formatMessage(
cardBadgesMessages[
`recommendation.2u-product-card.pill-text.${createCodeFriendlyProduct(productType)}`
],
);
const handleCardClick = () => {
trackRecommendationCardClickOptimizely(userId?.toString());
trackRecommendationsClicked(
product.courseKey,
false,
position + 1,
userId,
product.marketingUrl,
product.recommendationType || 'algolia',
);
};
return (
<BaseCard
url={product.url}
customHeaderImage={headerImage}
schoolLogo={isMultipleOwner ? '' : schoolLogo}
title={product.title}
uuid={product.uuid}
key={product.uuid}
subtitle={isMultipleOwner ? multipleSchoolNames : schoolName}
productTypeCopy={productTypeCopy}
productType={productType}
variant={variant}
footer={(
<Footer
footerMessage={footerMessagesObj?.[product.productSource?.slug]}
quickFacts={product.degree?.quickFacts}
externalUrl={product.additionalMetadata?.externalUrl
|| product.degree?.additionalMetadata?.externalUrl}
courseLength={product.courses?.length}
isSubscriptionView={!!product.subscriptionEligible}
is2UDegreeProgram={product.is2UDegreeProgram}
cardType={product.cardType}
/>
)}
handleOnClick={handleCardClick}
isSubscriptionView={!!product.subscriptionEligible}
/>
);
};
ProductCard.propTypes = {
product: PropTypes.shape([
PropTypes.shape({}),
]).isRequired,
userId: PropTypes.number.isRequired,
position: PropTypes.number.isRequired,
};
ProductCard.defaultProps = {
};
export default ProductCard;

View File

@@ -1,87 +0,0 @@
import React from 'react';
import { Card, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import { trackRecommendationCardClickOptimizely } from './optimizelyExperiment';
import { trackRecommendationsClicked } from './track';
const RecommendationCard = (props) => {
const { recommendation, position, userId } = props;
const showPartnerLogo = recommendation.owners.length === 1;
const getOwners = () => {
if (recommendation.owners.length === 1) {
return recommendation.owners[0].key;
}
let keys = '';
recommendation.owners.forEach((owner) => {
keys += `${owner.key }, `;
});
return keys.slice(0, -2);
};
const handleCardClick = () => {
trackRecommendationCardClickOptimizely(userId?.toString());
trackRecommendationsClicked(
recommendation.courseKey,
false,
position + 1,
userId,
recommendation.marketingUrl,
recommendation.recommendationType || 'algolia',
);
};
return (
<div className="mr-4 recommendation-card">
<Hyperlink
destination={recommendation.marketingUrl}
target="_blank"
className="card-box"
showLaunchIcon={false}
onClick={handleCardClick}
>
<Card isClickable>
<Card.ImageCap
src={recommendation.cardImageUrl}
srcAlt="Card image"
logoSrc={showPartnerLogo ? recommendation.owners[0].logoImageUrl : ''}
logoAlt="Card logo"
/>
<Card.Header
title={recommendation.title}
subtitle={getOwners()}
/>
<Card.Section />
<Card.Footer textElement={<small className="pgn__card__footer-text">Course</small>} />
</Card>
</Hyperlink>
</div>
);
};
RecommendationCard.propTypes = {
recommendation: PropTypes.shape({
courseKey: PropTypes.string.isRequired,
activeRunKey: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
cardImageUrl: PropTypes.string.isRequired,
owners: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
logoImageUrl: PropTypes.string.isRequired,
})),
marketingUrl: PropTypes.string.isRequired,
recommendationType: PropTypes.string,
}).isRequired,
position: PropTypes.number.isRequired,
userId: PropTypes.number,
};
RecommendationCard.defaultProps = {
userId: null,
};
export default RecommendationCard;

View File

@@ -1,24 +1,50 @@
import React from 'react';
import { Container } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container, Dropdown, DropdownButton } from '@edx/paragon';
import PropTypes from 'prop-types';
import RecommendationCard from './RecommendationCard';
import { RECOMMENDATIONS_OPTION_LIST } from './data/constants';
import messages from './messages';
import ProductCard from './ProductCard';
const RecommendationsList = (props) => {
const { title, recommendations, userId } = props;
const { formatMessage } = useIntl();
const {
title, recommendations, userId, setSelectedRecommendationsType, selectedRecommendationsType,
} = props;
return (
<Container id="course-recommendations" size="lg" className="recommendations-container">
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
{title}
</h2>
<DropdownButton
id="dropdown-basic-button"
title={(
<>
{formatMessage(messages[`recommendation.option.${selectedRecommendationsType.value}`])}
</>
)}
className="bg-white mt-5.5 mb-3"
>
{RECOMMENDATIONS_OPTION_LIST.map((option) => (
<Dropdown.Item
onClick={() => setSelectedRecommendationsType(option)}
id={`option-${option.value}`}
key={`option-${option.value}`}
>
{formatMessage(messages[`recommendation.option.${option.value}`])}
</Dropdown.Item>
))}
</DropdownButton>
<div className="d-flex recommendations-container__card-list">
{
recommendations.map((recommendation, idx) => (
<RecommendationCard
<ProductCard
key={recommendation.activeRunKey}
recommendation={recommendation}
product={recommendation}
position={idx}
userId={userId}
/>
@@ -31,6 +57,11 @@ const RecommendationsList = (props) => {
RecommendationsList.propTypes = {
title: PropTypes.string.isRequired,
setSelectedRecommendationsType: PropTypes.func.isRequired,
selectedRecommendationsType: PropTypes.shape({
title: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
}).isRequired,
recommendations: PropTypes.arrayOf(PropTypes.shape({
courseKey: PropTypes.string.isRequired,
activeRunKey: PropTypes.string.isRequired,

View File

@@ -1,19 +1,16 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Hyperlink, Image, Spinner, StatefulButton,
Hyperlink, Image, StatefulButton,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { EDUCATION_LEVEL_MAPPING, RECOMMENDATIONS_COUNT } from './data/constants';
import getPersonalizedRecommendations from './data/service';
import { convertCourseRunKeytoCourseKey } from './data/utils';
import { RECOMMENDATIONS_COUNT, RECOMMENDATIONS_OPTION_LIST } from './data/constants';
import messages from './messages';
import RecommendationsList from './RecommendationsList';
import { trackRecommendationsViewed } from './track';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
const RecommendationsPage = (props) => {
@@ -23,51 +20,12 @@ const RecommendationsPage = (props) => {
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const { formatMessage } = useIntl();
const [isLoading, setIsLoading] = useState(true);
const [recommendations, setRecommendations] = useState([]);
const [algoliaRecommendations, setAlgoliaRecommendations] = useState([]);
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
const [selectedRecommendationsType, setSelectedRecommendationsType] = useState(RECOMMENDATIONS_OPTION_LIST[1]);
const [recommendations] = useState([]);
useEffect(() => {
if (registrationResponse) {
const generalRecommendations = JSON.parse(getConfig().GENERAL_RECOMMENDATIONS);
let coursesWithKeys = [];
getPersonalizedRecommendations(educationLevel).then((response) => {
coursesWithKeys = response.map(course => ({
...course,
courseKey: convertCourseRunKeytoCourseKey(course.activeRunKey),
}));
setAlgoliaRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
if (coursesWithKeys.length >= RECOMMENDATIONS_COUNT) {
setRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
} else {
const courseRecommendations = coursesWithKeys.concat(generalRecommendations);
// Remove duplicate recommendations
const uniqueRecommendations = courseRecommendations.filter(
(recommendation, index, self) => index === self.findIndex((existingRecommendation) => (
existingRecommendation.courseKey === recommendation.courseKey
)),
);
setRecommendations(uniqueRecommendations.slice(0, RECOMMENDATIONS_COUNT));
}
setIsLoading(false);
})
.catch(() => {
setRecommendations(generalRecommendations.slice(0, RECOMMENDATIONS_COUNT));
setIsLoading(false);
});
}
}, [registrationResponse, DASHBOARD_URL, educationLevel, userId]);
useEffect(() => {
if (!isLoading) {
// We only want to track the recommendations returned by Algolia
const courseKeys = algoliaRecommendations.map(course => course.courseKey);
trackRecommendationsViewed(courseKeys, false, userId);
}
}, [isLoading, algoliaRecommendations, userId]);
const handleRecommendationType = (option) => {
setSelectedRecommendationsType(option);
};
if (!registrationResponse) {
global.location.assign(DASHBOARD_URL);
@@ -83,7 +41,7 @@ const RecommendationsPage = (props) => {
}
};
if (!isLoading && recommendations.length < RECOMMENDATIONS_COUNT) {
if (recommendations.length < RECOMMENDATIONS_COUNT) {
handleRedirection();
}
@@ -106,12 +64,14 @@ const RecommendationsPage = (props) => {
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
</div>
{(!isLoading && recommendations.length === RECOMMENDATIONS_COUNT) ? (
{(recommendations) && (
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
<RecommendationsList
title={formatMessage(messages['recommendation.page.heading'])}
recommendations={recommendations}
userId={userId}
setSelectedRecommendationsType={handleRecommendationType}
selectedRecommendationsType={selectedRecommendationsType}
/>
<div className="text-center">
<StatefulButton
@@ -125,10 +85,7 @@ const RecommendationsPage = (props) => {
/>
</div>
</div>
)
: (
<Spinner animation="border" variant="primary" className="spinner--position-centered" />
)}
)}
</div>
</>
);

View File

@@ -9,3 +9,10 @@ export const EDUCATION_LEVEL_MAPPING = {
hs: 'Introductory',
jhs: 'Introductory',
};
export const EXTERNAL_PRODUCT_SOURCES = {
EMERITUS: 'emeritus',
SHORELIGHT: 'shorelight',
};
export const RECOMMENDATIONS_OPTION_LIST = [{ title: 'Trending courses', value: 'trending' }, { title: 'Popular courses', value: 'popular' }];

View File

@@ -0,0 +1,16 @@
import { convertCourseRunKeytoCourseKey, useProductType } from '../utils';
describe('UtilsTests', () => {
it('should return the courseKey after parsing the activeCourseRun key', async () => {
const courseKey = convertCourseRunKeytoCourseKey('course-v1:Demox+Test101+2023');
expect(courseKey).toEqual('Demox+Test101');
});
it('should return courseType and programType', async () => {
const programType = useProductType(undefined, 'Professional Certificate');
expect(programType).toEqual('Professional Certificate');
const courseType = useProductType('verified-audit', undefined);
expect(courseType).toEqual('Course');
const noCourseType = useProductType(undefined, undefined);
expect(noCourseType).toEqual(undefined);
});
});

View File

@@ -12,6 +12,57 @@ export const convertCourseRunKeytoCourseKey = (courseRunKey) => {
return `${splitCourseKey[0]}+${splitCourseKey[1]}`;
};
export default {
convertCourseRunKeytoCourseKey,
const courseTypeToProductTypeMap = {
course: 'Course',
'verified-audit': 'Course',
verified: 'Course',
audit: 'Course',
'credit-verified-audit': 'Course',
'spoc-verified-audit': 'Course',
professional: 'Professional Certificate',
'bootcamp-2u': 'Boot Camp',
'executive-education-2u': 'Executive Education',
'executive-education': 'Executive Education',
masters: "Master's",
'masters-verified-audit': "Master's",
};
const programTypeToProductTypeMap = {
xseries: 'XSeries',
micromasters: 'MicroMasters',
microbachelors: 'MicroBachelors',
'professional certificate': 'Professional Certificate',
"bachelor's": "Bachelor's",
bachelors: "Bachelor's",
"master's": "Master's",
masters: "Master's",
doctorate: 'Doctorate',
license: 'License',
certificate: 'Certificate',
};
export const useProductType = (courseType, programType) => {
const courseTypeLowerCase = courseType?.toLowerCase();
if (courseTypeToProductTypeMap[courseTypeLowerCase]) {
return courseTypeToProductTypeMap[courseTypeLowerCase];
}
const programTypeLowerCase = programType?.toLowerCase();
if (programTypeToProductTypeMap[programTypeLowerCase]) {
return programTypeToProductTypeMap[programTypeLowerCase];
}
return undefined;
};
export const getVariant = (productType) => (
['Boot Camp', 'Executive Education', 'Course'].includes(productType) ? 'light' : 'dark'
);
export const createCodeFriendlyProduct = (type) => type?.replace(/\s+/g, '-').replace(/'/g, '').toLowerCase();
export const isEdxProgram = ({ cardType, is2UDegreeProgram }) => cardType === 'program' && !is2UDegreeProgram;
export const truncateText = (input) => (input?.length > 50 ? `${input.substring(0, 50)}...` : input);
export default convertCourseRunKeytoCourseKey;

View File

@@ -16,6 +16,111 @@ const messages = defineMessages({
defaultMessage: 'Skip for now',
description: 'Skip button text',
},
'recommendation.option.trending': {
id: 'recommendation.option.trending',
defaultMessage: 'Trending Courses',
description: 'Trending courses option',
},
'recommendation.option.popular': {
id: 'recommendation.option.popular',
defaultMessage: 'Popular Courses',
description: 'Popular courses option',
},
});
export const cardBadgesMessages = defineMessages({
'recommendation.2u-product-card.pill-text.course': {
id: 'recommendation.2u-product-card.pill-text.course',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Course',
},
'recommendation.2u-product-card.pill-text.microbachelors': {
id: 'recommendation.2u-product-card.pill-text.microbachelors',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'MicroBachelors®',
},
'recommendation.2u-product-card.pill-text.micromasters': {
id: 'recommendation.2u-product-card.pill-text.micromasters',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'MicroMasters®',
},
'recommendation.2u-product-card.pill-text.xseries': {
id: 'recommendation.2u-product-card.pill-text.xseries',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'XSeries',
},
'recommendation.2u-product-card.pill-text.professional-certificate': {
id: 'recommendation.2u-product-card.pill-text.professional-certificate',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Professional Certificate',
},
// 2U Products
'recommendation.2u-product-card.pill-text.executive-education': {
id: 'recommendation.2u-product-card.pill-text.executive-education',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Executive Education',
},
'recommendation.2u-product-card.pill-text.boot-camp': {
id: 'recommendation.2u-product-card.pill-text.boot-camp',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Boot Camp',
},
'recommendation.2u-product-card.pill-text.bachelors': {
id: 'recommendation.2u-product-card.pill-text.bachelors',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Bachelor\'s Degree',
},
'recommendation.2u-product-card.pill-text.masters': {
id: 'recommendation.2u-product-card.pill-text.masters',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Master\'s Degree',
},
'recommendation.2u-product-card.pill-text.doctorate': {
id: 'recommendation.2u-product-card.pill-text.doctorate',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Doctoral Program',
},
'recommendation.2u-product-card.pill-text.certificate': {
id: 'recommendation.2u-product-card.pill-text.certificate',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Certificate Program',
},
'recommendation.2u-product-card.pill-text.license': {
id: 'recommendation.2u-product-card.pill-text.license',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Licensure Program',
},
});
export const cardFooterMessages = defineMessages({
'recommendation.2u-product-card.footer-text.emeritus': {
id: 'recommendation.2u-product-card.pill-text.emeritus',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Offered on Emeritus',
},
'recommendation.2u-product-card.footer-text.shorelight': {
id: 'recommendation.2u-product-card.pill-text.shorelight',
description: 'Text on a product card that describes which product line this item belongs to',
defaultMessage: 'Offered through Shorelight',
},
'recommendation.2u-product-card.footer-text.number-of-courses': {
id: 'recommendation.2u-product-card.footer-text.number-of-courses',
description: 'Label in card footer that shows how many courses are in a program',
defaultMessage: '{length} {label}',
},
'recommendation.2u-product-card.footer-text.subscription': {
id: 'recommendation.2u-product-card.footer-text.subscription',
description: 'Label in card footer that describes that it is a subscription program',
defaultMessage: 'Subscription',
},
});
export const externalLinkIconMessages = defineMessages({
'recommendation.2u-product-card.launch-icon.sr-text': {
id: 'recommendation.2u-product-card.launch-icon.sr-text',
description: 'Screen reader text for the launch icon on the cards',
defaultMessage: 'Opens a link in a new tab',
},
});
export default messages;

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import configureStore from 'redux-mock-store';
import mockedCoursesData from './mockedData';
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
import RecommendationList from '../RecommendationsList';
const IntlRecommendationList = injectIntl(RecommendationList);
const mockStore = configureStore();
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
jest.mock('../data/service', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../optimizelyExperiment', () => ({
trackRecommendationCardClickOptimizely: jest.fn(),
}));
describe('RecommendationsListTests', () => {
mergeConfig({
GENERAL_RECOMMENDATIONS: '[]',
});
let defaultProps = {};
let store = {};
const registrationResult = {
redirectUrl: getConfig().LMS_BASE_URL.concat('/course-about-page-url'),
success: true,
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const getRecommendationsList = async (props = defaultProps) => {
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationList {...props} />));
await act(async () => {
await Promise.resolve(recommendationsPage);
recommendationsPage.update();
});
return recommendationsPage;
};
beforeEach(() => {
store = mockStore({});
defaultProps = {
location: {
state: {
registrationResult,
userId: 111,
},
},
};
});
it('should render the product card with formatted message in card', async () => {
const props = {
recommendations: [mockedCoursesData[4]],
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
expect(recommendationsList.find('.x-small').at(0).text()).toEqual('Offered on Emeritus');
});
it('should call trackRecommendationCardClickOptimizely when card is clicked', async () => {
const props = {
recommendations: [mockedCoursesData[1]],
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
recommendationsList.find('.card-box').first().simulate('click');
expect(trackRecommendationCardClickOptimizely).toHaveBeenCalledTimes(1);
});
it('should render the recommendations card with with facets', async () => {
const props = {
recommendations: [mockedCoursesData[1]],
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
expect(recommendationsList.find('.d-inline-block').at(0).text()).toEqual('6 Modules');
});
it('should render the recommendations card with footer content when subscription view is enabled', async () => {
const props = {
recommendations: [mockedCoursesData[3]],
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
expect(recommendationsList.find('.d-inline-block').at(1).text()).toEqual('Subscription');
});
it('should render the recommendations card with footer content', async () => {
const props = {
recommendations: [mockedCoursesData[0]],
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
expect(recommendationsList.find('.x-small').text()).toEqual('4 Courses');
});
it('should render the recommendations list', async () => {
const props = {
recommendations: mockedCoursesData,
title: 'We have a few recommendations to get you started.',
userId: 1234567,
setSelectedRecommendationsType: jest.fn(),
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
};
const recommendationsList = await getRecommendationsList(props);
expect(recommendationsList.find('.recommendation-card').length).toBe(5);
});
});

View File

@@ -2,16 +2,13 @@ import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import configureStore from 'redux-mock-store';
import { mockedGeneralRecommendations, mockedResponse } from './mockedData';
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
import getPersonalizedRecommendations from '../data/service';
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
import RecommendationsPage from '../RecommendationsPage';
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
@@ -24,9 +21,6 @@ jest.mock('../data/service', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../optimizelyExperiment', () => ({
trackRecommendationCardClickOptimizely: jest.fn(),
}));
describe('RecommendationsPageTests', () => {
mergeConfig({
@@ -68,19 +62,6 @@ describe('RecommendationsPageTests', () => {
};
});
it('redirects to dashboard if user tries to access the page directly', async () => {
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
delete window.location;
window.location = {
href: getConfig().BASE_URL,
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
};
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
await getRecommendationsPage({});
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(0);
expect(window.location.href).toEqual(DASHBOARD_URL);
});
it('redirects to dashboard if user click on skip button', async () => {
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
registrationResult = {
@@ -95,87 +76,39 @@ describe('RecommendationsPageTests', () => {
},
},
};
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
const recommendationsPage = await getRecommendationsPage(props);
recommendationsPage.find('button').simulate('click');
recommendationsPage.find('.pgn__stateful-btn-state-default').first().simulate('click');
expect(window.location.href).toEqual(DASHBOARD_URL);
});
it('should call trackRecommendationCardClickOptimizely when card is clicked', async () => {
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
const recommendationsPage = await getRecommendationsPage();
recommendationsPage.find('.card-box').first().simulate('click');
expect(trackRecommendationCardClickOptimizely).toHaveBeenCalledTimes(1);
});
it('should show loading state to user', async () => {
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
await act(async () => {
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage {...defaultProps} />));
expect(recommendationsPage.find('.spinner--position-centered').exists()).toBeTruthy();
});
});
it('should call getPersonalizedRecommendations', async () => {
delete window.location;
window.location = { assign: jest.fn() };
getPersonalizedRecommendations.mockClear();
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
await getRecommendationsPage();
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.bi.user.recommendations.viewed',
{
page: 'authn_recommendations',
course_key_array: [],
amplitude_recommendations: false,
is_control: false,
user_id: 111,
},
);
});
it('should display recommendations returned by Algolia', async () => {
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
it('should change the option when click the dropdown option', async () => {
const recommendationsPage = await getRecommendationsPage();
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
});
it('should not display recommendations if error comes in while fetching the recommendations', async () => {
getPersonalizedRecommendations.mockImplementation(() => Promise.reject(mockedResponse));
const recommendationsPage = await getRecommendationsPage();
expect(recommendationsPage.find('#recommendation-card').exists()).toBeFalsy();
recommendationsPage.find('#dropdown-basic-button').at(1).simulate('click');
recommendationsPage.find('.dropdown-item').at(0).simulate('click',
{ target: { title: 'Trending courses', value: 'trending' } });
expect(recommendationsPage.find('#dropdown-basic-button').at(1).text()).toEqual('Trending Courses');
});
it('should redirect if recommended courses count is less than RECOMMENDATIONS_COUNT', async () => {
delete window.location;
window.location = { assign: jest.fn() };
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([mockedResponse[0]]));
const recommendationsPage = await getRecommendationsPage();
expect(recommendationsPage.find('#course-recommendations').exists()).toBeFalsy();
expect(window.location.href).toEqual(registrationResult.redirectUrl);
});
it('should not redirect if fallback recommendations are enabled', async () => {
mergeConfig({
GENERAL_RECOMMENDATIONS: mockedGeneralRecommendations,
});
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
const recommendationsPage = await getRecommendationsPage();
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
});
it('should display all owners for a course', async () => {
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
const recommendationsPage = await getRecommendationsPage();
it('redirects to dashboard if user tries to access the page directly', async () => {
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
delete window.location;
window.location = {
href: getConfig().BASE_URL,
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
};
expect(
recommendationsPage.find('.pgn__card-header-subtitle-md').getElements()[0].props.children,
).toEqual('firstOwnerX, secondOwnerX');
await getRecommendationsPage({});
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(0);
expect(window.location.href).toEqual(DASHBOARD_URL);
});
});

View File

@@ -1,79 +1,241 @@
export const mockedResponse = [
const mockedCoursesData = [
{
title: 'How to Learn Online 1',
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-1',
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-1.png',
activeRunKey: 'course-v1:test+testX+2018',
owners: [
uuid: '1fcffef0-468a-483f-95aa-87e2d4d2c408',
title: 'Google Cloud Computing Foundations',
subtitle: 'The Google Cloud Computing Foundations courses provide an overview of concepts central to cloud basics, big data, and machine learning, and where and how Google Cloud fits in.',
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/1fcffef0-468a-483f-95aa-87e2d4d2c408-fc006d5bbc06.png',
authoringOrganizations: [
{
key: 'firstOwnerX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
name: 'first owner',
},
{
key: 'secondOwnerX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
name: 'second owner',
key: 'GoogleCloud',
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/7a30f2c7-0d59-4890-ab19-fc8324e9c7d6-6ff36c37bf48.png',
name: 'Google Cloud',
},
],
objectId: 'course-how-to-learn-online-key-1',
courses: [
{
course: {
title: 'Google Cloud Computing Foundations: Cloud Computing Fundamentals',
topics: [],
},
},
{
course: null,
},
{
course: null,
},
{
course: null,
},
],
type: 'Professional Certificate',
marketingPath: '/professional-certificate/google-cloud-computing-foundations',
organizationLogoOverrideUrl: null,
organizationShortCodeOverride: '',
productSource: {
name: 'edX',
slug: 'edx',
description: '',
},
status: 'active',
hidden: false,
inProspectus: true,
is2UDegreeProgram: false,
degree: null,
locationRestriction: null,
subscriptionEligible: null,
objectID: 'program-1fcffef0-468a-483f-95aa-87e2d4d2c408',
cardType: 'program',
cardIndex: 0,
},
{
title: 'How to Learn Online 2',
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-2',
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-2.png',
activeRunKey: 'course-v1:test+testX+2019',
owners: [
uuid: 'ff89bff4-42c4-4728-ae8b-7e9276ebfcac',
title: 'Online Masters Degree in Public Health',
subtitle: 'from Boston University',
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/ff89bff4-42c4-4728-ae8b-7e9276ebfcac-18ae533b0500.jpeg',
authoringOrganizations: [
{
key: 'testX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-2.png',
name: 'test',
key: 'BUx',
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/36cfd0bb-1d18-4355-ae44-cb946573df3c-1e18515c3e4b.png',
name: 'Boston University',
},
],
objectId: 'course-how-to-learn-online-key-2',
courses: [],
type: 'Masters',
marketingPath: '/masters/online-masters-in-public-health-boston-university',
organizationLogoOverrideUrl: null,
organizationShortCodeOverride: '',
productSource: {
name: 'edX',
slug: 'edx',
description: '',
},
status: 'active',
hidden: false,
inProspectus: true,
is2UDegreeProgram: false,
degree: {
quickFacts: [
{
text: '$24,000',
icon: 'fa-dollar',
},
{
text: '6 Modules',
icon: 'fa-book',
},
{
text: 'Fully Online',
icon: 'fa-desktop',
},
{
text: '24 Months',
icon: 'fa-clock-o',
},
],
additionalMetadata: null,
organizationLogoOverrideUrl: null,
},
locationRestriction: null,
subscriptionEligible: null,
objectID: 'program-ff89bff4-42c4-4728-ae8b-7e9276ebfcac',
cardType: 'program',
cardIndex: 1,
},
{
title: 'How to Learn Online 3',
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-3',
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-3.png',
activeRunKey: 'course-v1:test+testX+2020',
owners: [
uuid: '123f9327-bfef-459f-8bf2-c53277aa58f8',
title: 'Bachelor of Science in Data Science and Business Analytics',
subtitle: '',
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/123f9327-bfef-459f-8bf2-c53277aa58f8-60ed48b6667d.png',
authoringOrganizations: [
{
key: 'testX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-3.png',
name: 'test',
key: 'UniversityofLondon',
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/5427ee88-6dba-46ff-8ed8-87b71e3234da-0af1465ab67e.png',
name: 'University of London',
},
],
objectId: 'course-how-to-learn-online-key-3',
courses: [],
type: 'Bachelors',
marketingPath: '/bachelors/london-bachelor-of-science-in-data-science-and-business-analytics',
organizationLogoOverrideUrl: null,
organizationShortCodeOverride: 'University of London',
productSource: {
name: '2u',
slug: '2u',
description: '2U, Trilogy, Getsmarter -- external source for 2u courses and programs',
},
status: 'active',
hidden: false,
inProspectus: true,
is2UDegreeProgram: true,
degree: {
quickFacts: [],
additionalMetadata: {
externalIdentifier: '2ca2718e-643c-4430-b521-b9ebb91ad226',
externalUrl: 'https://programs.edx.org/requestinfo/lse?utm_source=edx&utm_medium=referral',
organicUrl: 'https://programs.edx.org/requestinfo/lse?utm_source=edx&utm_medium=referral',
},
organizationLogoOverrideUrl: null,
},
locationRestriction: {
restrictionType: 'blocklist',
countries: [
'IN',
],
states: [],
},
subscriptionEligible: null,
objectID: 'program-123f9327-bfef-459f-8bf2-c53277aa58f8',
cardType: 'program',
cardIndex: 0,
},
{
title: 'How to Learn Online 4',
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-4',
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-4.png',
activeRunKey: 'course-v1:test+testX+2021',
owners: [
uuid: '51037067-2c57-4a58-a963-91918ccee5c8',
title: 'Leading in a Remote Environment',
subtitle: 'Remote work presents unique challenges to maintaining relationships and achieving goals. Leaders need to be adaptive in order to mobilize their teams to meet these new challenges.',
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/51037067-2c57-4a58-a963-91918ccee5c8-b24ebade984c.jpeg',
authoringOrganizations: [
{
key: 'testX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-4.png',
name: 'test',
key: 'HarvardX',
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png',
name: 'Harvard University',
},
],
objectId: 'course-how-to-learn-online-key-4',
courses: [
{
course: {
title: 'Exercising Leadership: Foundational Principles',
topics: [],
},
},
{
course: {
title: 'Remote Work Revolution for Everyone',
topics: [],
},
},
],
type: 'Professional Certificate',
marketingPath: '/professional-certificate/harvardx-leading-in-a-remote-environment',
organizationLogoOverrideUrl: null,
organizationShortCodeOverride: '',
productSource: {
name: 'edX',
slug: 'edx',
description: '',
},
status: 'active',
hidden: false,
inProspectus: true,
is2UDegreeProgram: false,
degree: null,
locationRestriction: null,
subscriptionEligible: true,
objectID: 'program-51037067-2c57-4a58-a963-91918ccee5c8',
cardType: 'program',
cardIndex: 1,
},
{
title: 'How to Learn Online 5',
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-5',
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-5.png',
activeRunKey: 'course-v1:test+testX+2022',
owners: [
uuid: '89f39d1a-bb23-4944-b6e0-b51fe25ce932',
title: 'Master of Science in Artificial Intelligence (MSAI)',
subtitle: '',
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/89f39d1a-bb23-4944-b6e0-b51fe25ce932-c0b01712082d.jpg',
authoringOrganizations: [
{
key: 'testX',
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-5.png',
name: 'test',
key: 'UTAustinX',
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/9d38ad58-87fb-4a89-9f23-c8df318112e3-aec8e9e98a5f.png',
name: 'The University of Texas at Austin',
},
],
objectId: 'course-how-to-learn-online-key-5',
courses: [],
type: 'Masters',
marketingPath: '/masters/online-master-artificial-intelligence-utaustinx',
organizationLogoOverrideUrl: null,
organizationShortCodeOverride: '',
productSource: {
name: '2u',
slug: 'emeritus',
description: '2U, Trilogy, Getsmarter -- external source for 2u courses and programs',
},
status: 'active',
hidden: false,
inProspectus: true,
is2UDegreeProgram: true,
degree: {
quickFacts: [],
additionalMetadata: {
externalIdentifier: '89f39d1a-bb23-4944-b6e0-b51fe25ce932',
externalUrl: 'https://www.edx.org/masters/online-master-artificial-intelligence-utaustinx',
organicUrl: 'https://www.edx.org/masters/online-master-artificial-intelligence-utaustinx',
},
organizationLogoOverrideUrl: null,
},
locationRestriction: null,
subscriptionEligible: null,
objectID: 'program-89f39d1a-bb23-4944-b6e0-b51fe25ce932',
cardType: 'program',
cardIndex: 0,
},
];
export const mockedGeneralRecommendations = '[{"courseKey":"test+text1","activeRunKey":"course-v1:test+test1+2018","cardImageUrl":"https://test-recommendations.com/text-1.jpg","marketingUrl":"https://test-recommendations.com/test-1","objectId":"test-1","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-1.png","name":"General recommendation org 1"}],"title":"General recommendation 1","recommendationType":"general"},{"courseKey":"test+text2","activeRunKey":"course-v1:test+test2+2018","cardImageUrl":"https://test-recommendations.com/text-2.jpg","marketingUrl":"https://test-recommendations.com/test-2","objectId":"test-2","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-2.png","name":"General recommendation org 2"}],"title":"General recommendation 2","recommendationType":"general"},{"courseKey":"test+text3","activeRunKey":"course-v1:test+test3+2018","cardImageUrl":"https://test-recommendations.com/text-3.jpg","marketingUrl":"https://test-recommendations.com/test-3","objectId":"test-3","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-3.png","name":"General recommendation org 3"}],"title":"General recommendation 3","recommendationType":"general"},{"courseKey":"test+text4","activeRunKey":"course-v1:test+test4+2018","cardImageUrl":"https://test-recommendations.com/text-4.jpg","marketingUrl":"https://test-recommendations.com/test-4","objectId":"test-4","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-4.png","name":"General recommendation org 4"}],"title":"General recommendation 4","recommendationType":"general"},{"courseKey":"test+text5","activeRunKey":"course-v1:test+test5+2018","cardImageUrl":"https://test-recommendations.com/text-5.jpg","marketingUrl":"https://test-recommendations.com/test-5","objectId":"test-5","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-5.png","name":"General recommendation org 5"}],"title":"General recommendation 5","recommendationType":"general"}]';
export default mockedCoursesData;

View File

@@ -0,0 +1,133 @@
$card-height: 332px;
$header-height: 104px;
.base-card-link {
text-decoration: none;
}
.base-card-link:hover {
text-decoration: none;
}
.base-card {
height: $card-height;
/* stylelint-disable selector-max-type */
p {
margin-bottom: 0;
}
.pgn__card-image-cap {
height: $header-height;
object: {
fit: cover;
position: top center;
}
}
.pgn__card-logo-cap {
bottom: -1.5rem;
object: {
fit: scale-down;
position: center center;
}
}
.pgn__card-header-title-md {
font: {
size: 1.125rem !important; // 18px
weight: 700;
}
line-height: 24px;
}
.pgn__card-header-subtitle-md {
font: {
size: 0.875rem !important; // 14px
weight: 400;
}
line-height: 24px;
}
.product-badge {
position: absolute;
bottom: 2.75rem;
}
.footer-content {
position: absolute;
bottom: 1rem;
}
&.light {
background-color: $white;
.title {
color: $black;
}
.subtitle {
color: $gray-700;
}
.badge {
background-color: $light-500;
color: $black;
}
.footer-content {
color: $gray-700;
}
}
&.dark {
background-color: $primary-500;
.pgn__card-header-title-md {
color: $white;
}
.pgn__card-header-subtitle-md {
color: $light-200;
}
.title {
color: $white;
}
.subtitle {
color: $light-200;
}
.badge {
background-color: $dark-200;
color: $white;
}
.footer-content {
color: $light-200;
}
}
}
.base-card:hover {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15), 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15);
}
.base-card-link .base-card {
display: flex;
}
.base-card-image-show {
.pgn__card-image-cap {
display: block;
}
.pgn__card-logo-cap {
display: block !important;
}
}

View File

@@ -24,9 +24,29 @@
@include media-breakpoint-up(xxl) {
max-width: $max-width-lg + $grid-gutter-width !important;
}
.dropdown {
background-color: #FBFAF9 !important;
.dropdown-toggle {
background-color: #FBFAF9 !important;
border: none;
color: #000000;
font-weight: 700;
font-size: 22px;
}
.dropdown-toggle:hover {
background-color: #FBFAF9 !important;
border: none;
color: #000000;
}
.dropdown-toggle::after {
margin-left: 1.3em;
}
}
}
.recommendation-card {
cursor: pointer;
.pgn__hyperlink {
display: block;
}
@@ -65,3 +85,7 @@
line-height: 1.25rem;
}
}
.footer-icon{
height: 16px;
width: 16px;
}

View File

@@ -6,6 +6,7 @@
@import "_progressive_profiling_page.scss";
@import "_login_page.scss";
@import "_forgot_password.scss";
@import "_recommendations_card_base.scss";
//
// ----------------------------
// #COLORS