AA-449: Show discounted price in upgrade buttons & course exit (#311)

If an offer is active for the user, show the discounted price (and
a struck-out original price) on upgrade buttons in the course sock
and outline sidebar.

Also show the discount code and price in the course exit upgrade
screen.
This commit is contained in:
Michael Terry
2020-12-17 10:18:57 -05:00
committed by GitHub
parent c16da21602
commit 37a4dcce18
12 changed files with 245 additions and 91 deletions

View File

@@ -6,6 +6,7 @@ import {
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import { FormattedPricing } from '../../generic/upgrade-button';
import messages from './messages';
function OfferAlert({ intl, payload }) {
@@ -20,26 +21,12 @@ function OfferAlert({ intl, payload }) {
const {
code,
discountedPrice,
expirationDate,
originalPrice,
percentage,
upgradeUrl,
} = offer;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const fullPricing = (
<>
<span className="sr-only">
{intl.formatMessage(messages.srPrices, { discountedPrice, originalPrice })}
</span>
<span aria-hidden="true">
{/* the price discount and price original classes can be removed post REV-1512 experiment */}
<span className="price discount">{discountedPrice}</span> <del className="price original">{originalPrice}</del>
</span>
</>
);
return (
<Alert type={ALERT_TYPES.INFO}>
{/* the first-purchase-offer-banner class can be removed post REV-1512 experiment */}
@@ -57,7 +44,7 @@ function OfferAlert({ intl, payload }) {
{...timezoneFormatArgs}
/>
),
fullPricing,
fullPricing: <FormattedPricing offer={offer} />,
percentage,
}}
/>

View File

@@ -1,10 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
srPrices: {
id: 'learning.offer.screenReaderPrices',
defaultMessage: 'Original price: {originalPrice}, discount price: {discountedPrice}',
},
upgradeNow: {
id: 'learning.offer.upgradeNow',
defaultMessage: 'Upgrade now',

View File

@@ -185,7 +185,7 @@ function OutlineTab({ intl }) {
</div>
)}
</div>
{canShowUpgradeSock && <CourseSock ref={courseSock} verifiedMode={verifiedMode} />}
{canShowUpgradeSock && <CourseSock ref={courseSock} offer={offer} verifiedMode={verifiedMode} />}
</>
);
}

View File

@@ -7,11 +7,13 @@ import { Button } from '@edx/paragon';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { UpgradeButton } from '../../../generic/upgrade-button';
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
function UpgradeCard({ courseId, intl, onLearnMore }) {
const { org } = useModel('courses', courseId);
const {
offer,
verifiedMode,
} = useModel('outline', courseId);
@@ -44,16 +46,11 @@ function UpgradeCard({ courseId, intl, onLearnMore }) {
style={{ width: '124px' }}
/>
<div className="float-right d-flex flex-column align-items-center">
<Button
variant="success"
href={verifiedMode.upgradeUrl}
<UpgradeButton
offer={offer}
onClick={logClick}
>
{intl.formatMessage(messages.upgradeButton, {
price: verifiedMode.price,
symbol: verifiedMode.currencySymbol,
})}
</Button>
verifiedMode={verifiedMode}
/>
{onLearnMore && (
<Button
variant="link"

View File

@@ -114,7 +114,7 @@ function Course({
open
/>
)}
{canShowUpgradeSock && <CourseSock verifiedMode={verifiedMode} />}
{canShowUpgradeSock && <CourseSock offer={offer} verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
);

View File

@@ -17,6 +17,7 @@ import CelebrationMobile from './assets/celebration_456x328.gif';
import CelebrationDesktop from './assets/celebration_750x540.gif';
import certificate from '../../../generic/assets/edX_certificate.png';
import certificateLocked from '../../../generic/assets/edX_locked_certificate.png';
import { FormattedPricing } from '../../../generic/upgrade-button';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
@@ -43,6 +44,7 @@ function CourseCelebration({ intl }) {
certificateData,
end,
linkedinAddToProfileUrl,
offer,
org,
relatedPrograms,
verifiedMode,
@@ -86,10 +88,12 @@ function CourseCelebration({ intl }) {
</Hyperlink>
);
let buttonPrefix = null;
let buttonLocation;
let buttonText;
let buttonVariant = 'outline-primary';
let buttonEvent = null;
let buttonSuffix = null;
let certificateImage = certificate;
let footnote;
let message;
@@ -118,6 +122,19 @@ function CourseCelebration({ intl }) {
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadButton);
}
if (linkedinAddToProfileUrl) {
buttonPrefix = (
<Button
className="mr-3"
href={linkedinAddToProfileUrl}
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
>
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
</Button>
);
}
buttonEvent = 'view_cert';
visitEvent = 'celebration_with_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
@@ -151,8 +168,20 @@ function CourseCelebration({ intl }) {
break;
}
case 'requesting':
buttonText = intl.formatMessage(messages.requestCertificateButton);
// The requesting status needs a different button because it does a POST instead of a GET.
// So we don't set buttonLocation and instead define a custom button as a buttonPrefix.
buttonEvent = 'request_cert';
buttonPrefix = (
<Button
variant={buttonVariant}
onClick={() => {
logClick(org, courseId, administrator, buttonEvent);
dispatch(requestCert(courseId));
}}
>
{intl.formatMessage(messages.requestCertificateButton)}
</Button>
);
certHeader = intl.formatMessage(messages.certificateHeaderRequestable);
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
visitEvent = 'celebration_with_requestable_cert';
@@ -193,7 +222,7 @@ function CourseCelebration({ intl }) {
assignments in this course. Upon completion, you will receive a verified certificate which is a
valuable credential to improve your job prospects and advance your career, or highlight your
certificate in school applications."
values={{ price: verifiedMode.currencySymbol + verifiedMode.price }}
values={{ price: <FormattedPricing inline offer={offer} verifiedMode={verifiedMode} /> }}
/>
<br />
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
@@ -211,6 +240,20 @@ function CourseCelebration({ intl }) {
buttonEvent = 'upgrade';
buttonLocation = verifiedMode.upgradeUrl;
buttonVariant = 'primary';
if (offer) {
buttonSuffix = (
<span className="ml-2 align-middle">
<FormattedMessage
id="courseCelebration.upgradeDiscountCodePrompt"
defaultMessage="Use code {code} at checkout for {percent}% off!"
values={{
code: (<b>{offer.code}</b>),
percent: offer.percentage,
}}
/>
</span>
);
}
certificateImage = certificateLocked;
visitEvent = 'celebration_upgrade';
if (verifiedMode.accessExpirationDate) {
@@ -270,39 +313,19 @@ function CourseCelebration({ intl }) {
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
<div className="h4">{certHeader}</div>
{message}
{/* The requesting status needs a different button because it does a POST instead of a GET */}
{certStatus === 'requesting' && (
<Button
variant={buttonVariant}
onClick={() => {
logClick(org, courseId, administrator, buttonEvent);
dispatch(requestCert(courseId));
}}
>
{buttonText}
</Button>
)}
{certStatus === 'downloadable' && linkedinAddToProfileUrl && (
<Button
className="mr-3 mt-2"
href={linkedinAddToProfileUrl}
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
>
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
</Button>
)}
{buttonLocation && (
<Button
className="mt-2"
variant={buttonVariant}
href={buttonLocation}
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
>
{buttonText}
</Button>
)}
<div className="mt-2">
{buttonPrefix}
{buttonLocation && (
<Button
variant={buttonVariant}
href={buttonLocation}
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
>
{buttonText}
</Button>
)}
{buttonSuffix}
</div>
</div>
{certStatus !== 'unverified' && (
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">

View File

@@ -4,12 +4,12 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import LearnerQuote1 from './assets/learner-quote.png';
import LearnerQuote2 from './assets/learner-quote2.png';
import { UpgradeButton } from '../upgrade-button';
import VerifiedCert from '../assets/edX_certificate.png';
export default class CourseSock extends Component {
constructor(props) {
super(props);
this.verifiedMode = props.verifiedMode;
this.state = { showUpsell: false };
this.sockElement = React.createRef();
}
@@ -31,7 +31,7 @@ export default class CourseSock extends Component {
}
render() {
if (!this.verifiedMode) {
if (!this.props.verifiedMode) {
return null;
}
@@ -65,22 +65,14 @@ export default class CourseSock extends Component {
</div>
<div className="position-relative flex-grow-1 d-flex flex-column justify-content-end align-items-md-end">
<div style={{ position: 'sticky', bottom: '4rem' }}>
<a
href={this.verifiedMode.upgradeUrl}
className="btn btn-success btn-lg btn-upgrade focusable mb-3"
<UpgradeButton
size="lg"
offer={this.props.offer}
verifiedMode={this.props.verifiedMode}
className="mb-3"
data-creative="original_sock"
data-position="sock"
>
<FormattedMessage
id="coursesock.upsell.upgrade"
defaultMessage="Upgrade ({symbol}{price} {currency})"
values={{
symbol: this.verifiedMode.currencySymbol,
price: this.verifiedMode.price,
currency: this.verifiedMode.currency,
}}
/>
</a>
/>
</div>
</div>
</div>
@@ -215,12 +207,11 @@ export default class CourseSock extends Component {
}
}
CourseSock.propTypes = {
verifiedMode: PropTypes.shape({
price: PropTypes.number,
currency: PropTypes.string,
currencySymbol: PropTypes.string,
sku: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
CourseSock.defaultProps = {
offer: null,
};
CourseSock.propTypes = {
offer: PropTypes.shape({}),
verifiedMode: PropTypes.shape({}).isRequired,
};

View File

@@ -32,8 +32,8 @@ describe('Course Sock', () => {
fireEvent.click(upsellButton);
expect(screen.getByText('edX Verified Certificate')).toBeInTheDocument();
const { currencySymbol, price, currency } = mockData.verifiedMode;
expect(screen.getByText(`Upgrade (${currencySymbol}${price} ${currency})`)).toBeInTheDocument();
const { currencySymbol, price } = mockData.verifiedMode;
expect(screen.getByText(`Upgrade (${currencySymbol}${price})`)).toBeInTheDocument();
fireEvent.click(upsellButton);
expect(screen.queryByText('edX Verified Certificate')).not.toBeInTheDocument();

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function FormattedPricing(props) {
const {
inline,
intl,
offer,
verifiedMode,
} = props;
if (!offer) {
const {
currencySymbol,
price,
} = verifiedMode;
return `${currencySymbol}${price}`;
}
const {
discountedPrice,
originalPrice,
} = offer;
// The inline style is meant for being embedded in a sentence - it bolds the discount and leaves the original price
// as a parenthetical. The normal styling is more suited for a button, where the price and discount are side by side.
if (inline) {
return (
<>
<span className="font-weight-bold">{discountedPrice}</span>
&nbsp;(
<span className="sr-only">
{intl.formatMessage(messages.srInlinePrices, { originalPrice })}
</span>
<span aria-hidden="true">
<del>{originalPrice}</del>
</span>
)
</>
);
}
return (
<>
<span className="sr-only">
{intl.formatMessage(messages.srPrices, { discountedPrice, originalPrice })}
</span>
<span aria-hidden="true">
{/* the price discount and price original classes can be removed post REV-1512 experiment */}
<span className="price discount">{discountedPrice}</span> <del className="price original">{originalPrice}</del>
</span>
</>
);
}
FormattedPricing.defaultProps = {
inline: false,
offer: null,
verifiedMode: null,
};
FormattedPricing.propTypes = {
inline: PropTypes.bool,
intl: intlShape.isRequired,
offer: PropTypes.shape({
discountedPrice: PropTypes.string.isRequired,
originalPrice: PropTypes.string.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
verifiedMode: PropTypes.shape({
currencySymbol: PropTypes.string.isRequired,
price: PropTypes.number.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
};
export default injectIntl(FormattedPricing);

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import FormattedPricing from './FormattedPricing';
function UpgradeButton(props) {
const {
intl,
offer,
onClick,
verifiedMode,
...rest
} = props;
// Prefer offer's url in case it is ever different (though it is not at time of this writing)
const url = offer ? offer.upgradeUrl : verifiedMode.upgradeUrl;
return (
<Button
variant="success"
href={url}
onClick={onClick}
{...rest}
>
<FormattedMessage
id="learning.upgradeButton.buttonText"
defaultMessage="Upgrade ({pricing})"
values={{
pricing: (
<FormattedPricing
offer={offer}
verifiedMode={verifiedMode}
/>
),
}}
/>
</Button>
);
}
UpgradeButton.defaultProps = {
offer: null,
onClick: null,
};
UpgradeButton.propTypes = {
intl: intlShape.isRequired,
offer: PropTypes.shape({
upgradeUrl: PropTypes.string.isRequired,
}),
onClick: PropTypes.func,
verifiedMode: PropTypes.shape({
upgradeUrl: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(UpgradeButton);

View File

@@ -0,0 +1,7 @@
import FormattedPricing from './FormattedPricing';
import UpgradeButton from './UpgradeButton';
export {
FormattedPricing,
UpgradeButton,
};

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
srPrices: {
id: 'learning.offer.screenReaderPrices', // historic id
defaultMessage: 'Original price: {originalPrice}, discount price: {discountedPrice}',
},
srInlinePrices: {
id: 'learning.upgradeButton.screenReaderInlinePrices',
defaultMessage: 'Original price: {originalPrice}',
},
});
export default messages;