@@ -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' && (
>
diff --git a/src/courseware/course/course-exit/CourseExit.test.jsx b/src/courseware/course/course-exit/CourseExit.test.jsx
index 01b86c95..e30cdcd4 100644
--- a/src/courseware/course/course-exit/CourseExit.test.jsx
+++ b/src/courseware/course/course-exit/CourseExit.test.jsx
@@ -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();
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();
+ // 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();
+ // 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: {
diff --git a/src/courseware/course/course-exit/UpgradeFootnote.jsx b/src/courseware/course/course-exit/UpgradeFootnote.jsx
new file mode 100644
index 00000000..31a96afd
--- /dev/null
+++ b/src/courseware/course/course-exit/UpgradeFootnote.jsx
@@ -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 = (
+
+ {intl.formatMessage(messages.upgradeLink)}
+
+ );
+
+ const expirationDate = (
+
+ );
+
+ return (
+
+ )}
+ />
+ );
+}
+
+UpgradeFootnote.propTypes = {
+ deadline: PropTypes.instanceOf(Date).isRequired,
+ href: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(UpgradeFootnote);
diff --git a/src/courseware/course/course-exit/assets/edx_certificate_locked.png b/src/courseware/course/course-exit/assets/edx_certificate_locked.png
new file mode 100644
index 00000000..dd9187c6
Binary files /dev/null and b/src/courseware/course/course-exit/assets/edx_certificate_locked.png differ
diff --git a/src/courseware/course/course-exit/messages.js b/src/courseware/course/course-exit/messages.js
index df4e3b7e..392587da 100644
--- a/src/courseware/course/course-exit/messages.js
+++ b/src/courseware/course/course-exit/messages.js
@@ -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',
diff --git a/src/courseware/course/course-exit/utils.js b/src/courseware/course/course-exit/utils.js
index a7bbfb16..df225fb2 100644
--- a/src/courseware/course/course-exit/utils.js
+++ b/src/courseware/course/course-exit/utils.js
@@ -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;
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
index 567fa54a..a1b023b1 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx
@@ -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();
+ 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(
+ ,
+ { store: testStore },
+ );
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
index fe891763..b483e7f9 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx
@@ -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();
+ 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(
+ ,
+ { store: testStore },
+ );
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
diff --git a/src/courseware/data/__factories__/courseMetadata.factory.js b/src/courseware/data/__factories__/courseMetadata.factory.js
index 4647ac31..37ca76ee 100644
--- a/src/courseware/data/__factories__/courseMetadata.factory.js
+++ b/src/courseware/data/__factories__/courseMetadata.factory.js
@@ -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',