AA-197: Handle non-cert learners that can upgrade (#263)

Tell them about verified certificates and link to ecommerce.

Also fixes AA-376 by handling the no-verified-mode-to-upgrade-to
case.
This commit is contained in:
Michael Terry
2020-10-27 11:28:52 -04:00
committed by GitHub
parent 4eb52a592d
commit 6f415544be
9 changed files with 209 additions and 21 deletions

View File

@@ -15,10 +15,12 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import CelebrationMobile from './assets/celebration_456x328.gif';
import CelebrationDesktop from './assets/celebration_750x540.gif';
import certificate from './assets/edx_certificate.png';
import certificateLocked from './assets/edx_certificate_locked.png';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
import DashboardFootnote from './DashboardFootnote';
import UpgradeFootnote from './UpgradeFootnote';
const LINKEDIN_BLUE = '#007fb1';
@@ -37,6 +39,7 @@ function CourseCelebration({ intl }) {
certificateData,
end,
linkedinAddToProfileUrl,
verifiedMode,
verifyIdentityUrl,
} = useModel('courses', courseId);
@@ -44,7 +47,7 @@ function CourseCelebration({ intl }) {
certStatus,
certWebViewUrl,
downloadUrl,
} = certificateData;
} = certificateData || {};
const { administrator, username } = getAuthenticatedUser();
@@ -87,6 +90,10 @@ function CourseCelebration({ intl }) {
let buttonLocation;
let buttonText;
let buttonBackground = 'bg-white';
let buttonVariant = 'outline-primary';
let certificateImage = certificate;
let footnote;
let message;
let title;
// These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
@@ -112,6 +119,7 @@ function CourseCelebration({ intl }) {
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadButton);
}
footnote = <DashboardFootnote />;
break;
case 'earned_but_not_available': {
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
@@ -137,12 +145,14 @@ function CourseCelebration({ intl }) {
</p>
</>
);
footnote = <DashboardFootnote />;
break;
}
case 'requesting':
buttonText = intl.formatMessage(messages.requestCertificateButton);
title = intl.formatMessage(messages.certificateHeaderRequestable);
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
footnote = <DashboardFootnote />;
break;
case 'unverified':
buttonText = intl.formatMessage(messages.verifyIdentityButton);
@@ -159,6 +169,46 @@ function CourseCelebration({ intl }) {
/>
</p>
);
footnote = <DashboardFootnote />;
break;
case 'audit_passing':
case 'honor_passing':
if (verifiedMode) {
title = intl.formatMessage(messages.certificateHeaderUpgradable);
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.upgradable"
defaultMessage="Its not too late to upgrade. For {price} you will unlock access to all graded
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 }}
/>
<br />
{ /* todo: remove this hardcoded link to edX support */ }
{getConfig().SUPPORT_URL && (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/206502008-What-is-a-verified-certificate`}
>
{intl.formatMessage(messages.verifiedCertificateSupportLink)}
</Hyperlink>
)}
</p>
);
buttonText = intl.formatMessage(messages.upgradeButton);
buttonLocation = verifiedMode.upgradeUrl;
buttonBackground = '';
buttonVariant = 'primary';
certificateImage = certificateLocked;
if (verifiedMode.accessExpirationDate) {
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
} else {
footnote = <DashboardFootnote />;
}
}
break;
default:
break;
@@ -194,6 +244,7 @@ function CourseCelebration({ intl }) {
</OnAtLeastTablet>
</div>
<div className="col-12 px-0 px-md-5">
{title && (
<Alert variant="primary" className="row w-100 m-0">
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
<div className="h4">{title}</div>
@@ -201,8 +252,8 @@ function CourseCelebration({ intl }) {
{/* The requesting status needs a different button because it does a POST instead of a GET */}
{certStatus === 'requesting' && (
<Button
className="bg-white"
variant="outline-primary"
className={buttonBackground}
variant={buttonVariant}
onClick={() => dispatch(requestCert(courseId))}
>
{buttonText}
@@ -221,8 +272,8 @@ function CourseCelebration({ intl }) {
)}
{buttonLocation && (
<Button
className="bg-white mb-2 mb-sm-0"
variant="outline-primary"
className={`${buttonBackground} mb-2 mb-sm-0`}
variant={buttonVariant}
href={buttonLocation}
>
{buttonText}
@@ -232,7 +283,7 @@ function CourseCelebration({ intl }) {
{certStatus !== 'unverified' && (
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
<img
src={certificate}
src={certificateImage}
alt={`${intl.formatMessage(messages.certificateImage)}`}
className="w-100"
style={{ maxWidth: '13rem' }}
@@ -240,7 +291,8 @@ function CourseCelebration({ intl }) {
</div>
)}
</Alert>
<DashboardFootnote />
)}
{footnote}
</div>
</div>
</>

View File

@@ -72,6 +72,11 @@ describe('Course Exit Pages', () => {
});
it('Redirects if it does not match any statuses', async () => {
setMetadata({
certificate_data: {
cert_status: 'bogus_status',
},
});
await fetchAndRender(<CourseExit />);
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
});
@@ -123,6 +128,38 @@ describe('Course Exit Pages', () => {
expect(screen.queryByRole('img', { name: 'Sample certificate' })).not.toBeInTheDocument();
});
it('Displays upgrade link when available', async () => {
setMetadata({
certificate_data: { cert_status: 'audit_passing' },
verified_mode: {
access_expiration_date: '9999-08-06T12:00:00Z',
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
price: 600,
currency_symbol: '€',
},
});
await fetchAndRender(<CourseCelebration />);
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
// never actually there, when/if the text changes.
expect(screen.getByText('Upgrade to pursue a verified certificate')).toBeInTheDocument();
expect(screen.getByText('For €600 you will unlock access', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
const node = screen.getByText('Access to this course and its materials', { exact: false });
expect(node.textContent).toMatch(/until August 6, 9999\./);
});
it('Displays nothing if audit only', async () => {
setMetadata({
certificate_data: { cert_status: 'audit_passing' },
verified_mode: null,
});
await fetchAndRender(<CourseCelebration />);
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
// never actually there, when/if the text changes.
expect(screen.queryByText('Upgrade to pursue a verified certificate')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Upgrade now' })).not.toBeInTheDocument();
});
it('Displays LinkedIn Add to Profile button', async () => {
setMetadata({
certificate_data: {

View File

@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import Footnote from './Footnote';
import messages from './messages';
function UpgradeFootnote({ deadline, href, intl }) {
const upgradeLink = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={href}
className="text-reset"
>
{intl.formatMessage(messages.upgradeLink)}
</Hyperlink>
);
const expirationDate = (
<FormattedDate
day="numeric"
month="long"
year="numeric"
value={deadline}
/>
);
return (
<Footnote
icon={faCalendarAlt}
text={(
<FormattedMessage
id="courseExit.upgradeFootnote"
defaultMessage="Access to this course and its materials are available on your dashboard until {expirationDate}. To extend access, {upgradeLink}."
values={{
expirationDate,
upgradeLink,
}}
/>
)}
/>
);
}
UpgradeFootnote.propTypes = {
deadline: PropTypes.instanceOf(Date).isRequired,
href: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(UpgradeFootnote);

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -21,6 +21,10 @@ const messages = defineMessages({
defaultMessage: 'Congratulations, you qualified for a certificate!',
description: 'Text displayed when a user has completed the course and can request a certificate',
},
certificateHeaderUpgradable: {
id: 'courseCelebration.certificateHeader.upgradable',
defaultMessage: 'Upgrade to pursue a verified certificate',
},
certificateImage: {
id: 'courseCelebration.certificateImage',
defaultMessage: 'Sample certificate',
@@ -93,6 +97,18 @@ const messages = defineMessages({
id: 'courseCelebration.shareHeader',
defaultMessage: 'You have completed your course. Share your success on social media or email.',
},
upgradeButton: {
id: 'courseExit.upgradeButton',
defaultMessage: 'Upgrade now',
},
upgradeLink: {
id: 'courseExit.upgradeLink',
defaultMessage: 'upgrade now',
},
verifiedCertificateSupportLink: {
id: 'courseExit.verifiedCertificateSupportLink',
defaultMessage: 'Learn more about verified certificates',
},
verifyIdentityButton: {
id: 'courseCelebration.verifyIdentityButton',
defaultMessage: 'Verify ID now',

View File

@@ -10,14 +10,16 @@ const COURSE_EXIT_MODES = {
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
const CELEBRATION_STATUSES = [
'audit_passing',
'downloadable',
'earned_but_not_available',
'honor_passing',
'requesting',
'unverified',
];
const NON_CERTIFICATE_STATUSES = [ // no certificate will be given, though a valid certificateData block is provided
'audit_passing',
'honor_passing', // honor can be configured to not give a certificate
'honor_passing', // provided when honor is configured to not give a certificate
];
function getCourseExitMode(courseId) {
@@ -27,19 +29,28 @@ function getCourseExitMode(courseId) {
userHasPassingGrade,
} = useModel('courses', courseId);
if (!courseExitPageIsActive || !certificateData) {
if (!courseExitPageIsActive) {
return COURSE_EXIT_MODES.disabled;
}
const {
certStatus,
} = certificateData;
const isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
// Set defaults for our status-calculated variables, used when no certificateData is provided.
// This happens when `get_cert_data` in edx-platform returns None, which it does if we are
// in a certificate-earning mode, but the certificate is not available (maybe they didn't pass
// or course is not set up for certificates or something). Audit users will always have a
// certificateData sent over.
let isCelebratoryStatus = true;
let isEligibleForCertificate = true;
if (certificateData) {
const { certStatus } = certificateData;
isCelebratoryStatus = CELEBRATION_STATUSES.indexOf(certStatus) !== -1;
isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
}
if (isEligibleForCertificate && !userHasPassingGrade) {
return COURSE_EXIT_MODES.nonPassing;
}
if (CELEBRATION_STATUSES.indexOf(certStatus) !== -1) {
if (isCelebratoryStatus) {
return COURSE_EXIT_MODES.celebration;
}
return COURSE_EXIT_MODES.disabled;

View File

@@ -98,11 +98,17 @@ describe('Sequence Navigation', () => {
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit of the sequence', () => {
render(<SequenceNavigation
{...mockData}
unitId={unitBlocks[unitBlocks.length - 1].id}
/>);
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
const testMetadata = { ...courseMetadata, certificate_data: { cert_status: 'bogus_status' }, user_has_passing_grade: true };
const testStore = await initializeTestStore({ courseMetadata: testMetadata, unitBlocks }, false);
// Have to refetch the sequenceId since the new store generates new sequences
const { courseware } = testStore.getState();
const testData = { ...mockData, sequenceId: courseware.sequenceId };
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();

View File

@@ -73,8 +73,17 @@ describe('Unit Navigation', () => {
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[unitBlocks.length - 1].id} />);
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
const testCourseMetadata = { ...courseMetadata, certificate_data: { cert_status: 'bogus_status' }, user_has_passing_grade: true };
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
// Have to refetch the sequenceId since the new store generates new sequences
const { courseware } = testStore.getState();
const testData = { ...mockData, sequenceId: courseware.sequenceId };
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();

View File

@@ -26,6 +26,7 @@ Factory.define('courseMetadata')
is_active: null,
},
verified_mode: {
access_expiration_date: null,
currency: 'USD',
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
sku: '8CF08E5',