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:
@@ -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="It’s 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>
|
||||
</>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
56
src/courseware/course/course-exit/UpgradeFootnote.jsx
Normal file
56
src/courseware/course/course-exit/UpgradeFootnote.jsx
Normal 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 |
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user