diff --git a/src/courseware/course/course-exit/CourseCelebration.jsx b/src/courseware/course/course-exit/CourseCelebration.jsx index 2b099724..67d29426 100644 --- a/src/courseware/course/course-exit/CourseCelebration.jsx +++ b/src/courseware/course/course-exit/CourseCelebration.jsx @@ -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 = ; break; case 'earned_but_not_available': { const endDate = ; @@ -137,12 +145,14 @@ function CourseCelebration({ intl }) {

); + footnote = ; break; } case 'requesting': buttonText = intl.formatMessage(messages.requestCertificateButton); title = intl.formatMessage(messages.certificateHeaderRequestable); message = (

{intl.formatMessage(messages.requestCertificateBodyText)}

); + footnote = ; break; case 'unverified': buttonText = intl.formatMessage(messages.verifyIdentityButton); @@ -159,6 +169,46 @@ function CourseCelebration({ intl }) { />

); + footnote = ; + break; + case 'audit_passing': + case 'honor_passing': + if (verifiedMode) { + title = intl.formatMessage(messages.certificateHeaderUpgradable); + message = ( +

+ +
+ { /* todo: remove this hardcoded link to edX support */ } + {getConfig().SUPPORT_URL && ( + + {intl.formatMessage(messages.verifiedCertificateSupportLink)} + + )} +

+ ); + buttonText = intl.formatMessage(messages.upgradeButton); + buttonLocation = verifiedMode.upgradeUrl; + buttonBackground = ''; + buttonVariant = 'primary'; + certificateImage = certificateLocked; + if (verifiedMode.accessExpirationDate) { + footnote = ; + } else { + footnote = ; + } + } break; default: break; @@ -194,6 +244,7 @@ function CourseCelebration({ intl }) {
+ {title && (
{title}
@@ -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',