diff --git a/src/courseware/course/course-exit/CourseCelebration.jsx b/src/courseware/course/course-exit/CourseCelebration.jsx
new file mode 100644
index 00000000..cd7735e1
--- /dev/null
+++ b/src/courseware/course/course-exit/CourseCelebration.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+
+import {
+ FormattedDate, FormattedMessage, injectIntl, intlShape,
+} from '@edx/frontend-platform/i18n';
+import { Helmet } from 'react-helmet';
+import { useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
+import { Button, Hyperlink } from '@edx/paragon';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+
+import celebration from './assets/celebration_456x328.gif';
+import certificate from './assets/certificate.png';
+import messages from './messages';
+import { useModel } from '../../../generic/model-store';
+import { requestCert } from '../../../course-home/data/thunks';
+
+function CourseCelebration({ intl }) {
+ const { courseId } = useParams();
+ const dispatch = useDispatch();
+ const {
+ certificateData,
+ end,
+ verifyIdentityUrl,
+ } = useModel('courses', courseId);
+
+ const {
+ certStatus,
+ certWebViewUrl,
+ downloadUrl,
+ } = certificateData || { downloadUrl: 'google.com', certWebViewUrl: 'duckduckgo.com' };
+
+ const { username } = getAuthenticatedUser();
+
+ const dashboardLink = (
+
+ {intl.formatMessage(messages.dashboardLink)}
+
+ );
+ const idVerificationSupportLink = getConfig().SUPPORT_URL && (
+
+ {intl.formatMessage(messages.idVerificationSupportLink)}
+
+ );
+ const profileLink = (
+
+ {intl.formatMessage(messages.profileLink)}
+
+ );
+
+ let buttonLocation;
+ let buttonText;
+ let message;
+ let title;
+ // These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
+ switch (certStatus) {
+ case 'downloadable':
+ title = intl.formatMessage(messages.certificateHeaderDownloadable);
+ message = (
+
+ );
+ if (certWebViewUrl) {
+ buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
+ buttonText = intl.formatMessage(messages.viewCertificateButton);
+ } else if (downloadUrl) {
+ buttonLocation = downloadUrl;
+ buttonText = intl.formatMessage(messages.downloadButton);
+ }
+ break;
+ case 'earned_but_not_available': {
+ const endDate = ;
+ title = intl.formatMessage(messages.certificateHeaderNotAvailable);
+ message = (
+
+ );
+ break;
+ }
+ case 'requesting':
+ buttonText = intl.formatMessage(messages.requestCertificateButton);
+ title = intl.formatMessage(messages.certificateHeaderRequestable);
+ message = intl.formatMessage(messages.requestCertificateBodyText);
+ break;
+ case 'unverified':
+ buttonText = intl.formatMessage(messages.verifyIdentityButton);
+ buttonLocation = verifyIdentityUrl;
+ title = intl.formatMessage(messages.certificateHeaderUnverified);
+ message = (
+
+ );
+ break;
+ default:
+ break;
+ }
+
+ return (
+ <>
+
+ {`Congratulations! | ${getConfig().SITE_NAME}`}
+
+
+
{ intl.formatMessage(messages.congratulationsHeader) }
+
{ intl.formatMessage(messages.shareHeader) }
+
+
+
+
{title}
+ {message}
+ {/* The requesting status needs a different button because it does a POST instead of a GET */}
+ {certStatus === 'requesting' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {certStatus !== 'unverified' && (
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+CourseCelebration.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(CourseCelebration);
diff --git a/src/courseware/course/course-exit/CourseExit.jsx b/src/courseware/course/course-exit/CourseExit.jsx
new file mode 100644
index 00000000..a1e283cd
--- /dev/null
+++ b/src/courseware/course/course-exit/CourseExit.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import { Redirect, useParams } from 'react-router-dom';
+
+import CourseCelebration from './CourseCelebration';
+import { useModel } from '../../../generic/model-store';
+
+// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
+const CELEBRATION_STATUSES = [
+ 'downloadable',
+ 'earned_but_not_available',
+ 'requesting',
+ 'unverified',
+];
+
+export default function CourseExit() {
+ const { courseId } = useParams();
+ const {
+ courseExitPageIsActive,
+ userHasPassingGrade,
+ certificateData,
+ } = useModel('courses', courseId);
+
+ // userHasPassingGrade can be removed once there is an experience for failing learners
+ if (!courseExitPageIsActive || !userHasPassingGrade) {
+ return ();
+ }
+
+ const {
+ certStatus,
+ } = certificateData;
+
+ if (CELEBRATION_STATUSES.indexOf(certStatus) !== -1) {
+ return ();
+ }
+ // Just to be safe
+ return ();
+}
diff --git a/src/courseware/course/course-exit/assets/celebration_456x328.gif b/src/courseware/course/course-exit/assets/celebration_456x328.gif
new file mode 100644
index 00000000..69635758
Binary files /dev/null and b/src/courseware/course/course-exit/assets/celebration_456x328.gif differ
diff --git a/src/courseware/course/course-exit/assets/certificate.png b/src/courseware/course/course-exit/assets/certificate.png
new file mode 100644
index 00000000..d258e35a
Binary files /dev/null and b/src/courseware/course/course-exit/assets/certificate.png differ
diff --git a/src/courseware/course/course-exit/messages.js b/src/courseware/course/course-exit/messages.js
new file mode 100644
index 00000000..938aca03
--- /dev/null
+++ b/src/courseware/course/course-exit/messages.js
@@ -0,0 +1,76 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ certificateHeaderDownloadable: {
+ id: 'courseCelebration.certificateHeader.downloadable',
+ defaultMessage: 'Your certificate is available!',
+ description: 'Text displayed when course certificate is ready to be downloaded',
+ },
+ certificateHeaderNotAvailable: {
+ id: 'courseCelebration.certificateHeader.notAvailable',
+ defaultMessage: 'Your certificate will be available soon!',
+ description: 'Text displayed when course certificate is not yet available to view',
+ },
+ certificateHeaderUnverified: {
+ id: 'courseCelebration.certificateHeader.unverified',
+ defaultMessage: 'You must complete verification to receive your certificate.',
+ description: 'Text displayed when a user has not verified their identity and cannot view their course certificate',
+ },
+ certificateHeaderRequestable: {
+ id: 'courseCelebration.certificateHeader.requestable',
+ defaultMessage: 'Congratulations, you qualified for a certificate!',
+ description: 'Text displayed when a user has completed the course and can request a certificate',
+ },
+ congratulationsHeader: {
+ id: 'courseCelebration.congratulationsHeader',
+ defaultMessage: 'Congratulations!',
+ description: 'Congratulations header on course completion page',
+ },
+ dashboardLink: {
+ id: 'courseExit.dashboardLink',
+ defaultMessage: 'Dashboard',
+ description: "Link to learner's dashboard",
+ },
+ downloadButton: {
+ id: 'courseCelebration.downloadButton',
+ defaultMessage: 'Download my certificate',
+ description: 'Text for download button in certificate banner',
+ },
+ idVerificationSupportLink: {
+ id: 'courseExit.idVerificationSupportLink',
+ defaultMessage: 'Learn more about ID verification',
+ description: 'Link to help article about ID verification',
+ },
+ profileLink: {
+ id: 'courseExit.profileLink',
+ defaultMessage: 'Profile',
+ description: 'Link to profile',
+ },
+ requestCertificateBodyText: {
+ id: 'courseCelebration.requestCertificateBodyText',
+ defaultMessage: 'In order to access your certificate, request it below.',
+ description: 'Body text for certificate banner',
+ },
+ requestCertificateButton: {
+ id: 'courseCelebration.requestCertificateButton',
+ defaultMessage: 'Request certificate',
+ description: 'Text for request certificate button in certificate banner',
+ },
+ shareHeader: {
+ id: 'courseCelebration.shareHeader',
+ defaultMessage: 'You have completed your course. Share your success on social media or email.',
+ description: 'Social media/email share header',
+ },
+ verifyIdentityButton: {
+ id: 'courseCelebration.verifyIdentityButton',
+ defaultMessage: 'Verify now',
+ description: 'Text for verify identity button in certificate banner',
+ },
+ viewCertificateButton: {
+ id: 'courseCelebration.viewCertificateButton',
+ defaultMessage: 'View now',
+ description: 'Text for view certificate button in certificate banner',
+ },
+});
+
+export default messages;
diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx
index 2f1c92c5..34bce248 100644
--- a/src/courseware/course/sequence/Sequence.jsx
+++ b/src/courseware/course/sequence/Sequence.jsx
@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
+import { history } from '@edx/frontend-platform';
import PageLoading from '../../../generic/PageLoading';
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
@@ -113,6 +114,9 @@ function Sequence({
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
+ const goToCourseExitPage = () => {
+ history.push(`/course/${courseId}/course-exit`);
+ };
if (sequenceStatus === 'loaded') {
return (
@@ -134,6 +138,7 @@ function Sequence({
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
+ goToCourseExitPage={() => goToCourseExitPage()}
/>
goToCourseExitPage()}
/>
)}
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
index 2fc39ef9..e87b0612 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
@@ -20,13 +20,21 @@ export default function SequenceNavigation({
onNavigate,
nextSequenceHandler,
previousSequenceHandler,
+ goToCourseExitPage,
}) {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
- const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
+ const {
+ courseId,
+ sequenceStatus,
+ } = useSelector(state => state.courseware);
const isLocked = sequenceStatus === LOADED ? (
sequence.gatedContent !== undefined && sequence.gatedContent.gated
) : undefined;
+ const {
+ courseExitPageIsActive,
+ userHasPassingGrade,
+ } = useModel('courses', courseId);
const renderUnitButtons = () => {
if (isLocked) {
@@ -49,6 +57,52 @@ export default function SequenceNavigation({
);
};
+ const renderNextButton = () => {
+ // AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
+ const buttonOnClick = (isLastUnit && courseExitPageIsActive && userHasPassingGrade
+ ? goToCourseExitPage : nextSequenceHandler);
+ // AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
+ const disabled = isLastUnit && (!courseExitPageIsActive || !userHasPassingGrade);
+
+ let buttonText = (
+
+ );
+ if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
+ buttonText = (
+
+ );
+ }
+ // AA-198: Uncomment once there is a view for learners with failing grades
+ // else if (isLastUnit && courseExitPageIsActive && !userHasPassingGrade) {
+ // buttonText = (
+ //
+ //
+ // )
+ // }
+ return (
+
+ );
+ };
+
return sequenceStatus === LOADED && (
);
}
@@ -79,6 +126,7 @@ SequenceNavigation.propTypes = {
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
+ goToCourseExitPage: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
index 55c15768..fb14a872 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx
@@ -4,7 +4,9 @@ import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { useSelector } from 'react-redux';
import { useSequenceNavigationMetadata } from './hooks';
+import { useModel } from '../../../../generic/model-store';
export default function UnitNavigation(props) {
const {
@@ -12,9 +14,77 @@ export default function UnitNavigation(props) {
unitId,
onClickPrevious,
onClickNext,
+ goToCourseExitPage,
} = props;
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
+ const { courseId } = useSelector(state => state.courseware);
+ const {
+ courseExitPageIsActive,
+ userHasPassingGrade,
+ } = useModel('courses', courseId);
+
+ const renderNextButton = () => {
+ // AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
+ const buttonOnClick = (isLastUnit && courseExitPageIsActive && userHasPassingGrade
+ ? goToCourseExitPage : onClickNext);
+ // AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
+ const disabled = isLastUnit && (!courseExitPageIsActive || !userHasPassingGrade);
+
+ // This is just to support what used to show while we are getting courseExitPageIsActive turned on.
+ // This should be good to remove once disabled goes away.
+ if (disabled) {
+ return (
+
+ 🤗 {/* This is a hugging face emoji */}
+ {' '}
+
+
+ );
+ }
+
+ let buttonText = (
+
+ );
+ if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
+ buttonText = (
+
+ );
+ }
+ // AA-198: Uncomment once there is a view for learners with failing grades
+ // else if (isLastUnit && courseExitPageIsActive && !userHasPassingGrade) {
+ // buttonText = (
+ //
+ //
+ // )
+ // }
+ return (
+
+ );
+ };
return (
@@ -31,31 +101,7 @@ export default function UnitNavigation(props) {
defaultMessage="Previous"
/>
- {isLastUnit ? (
-
- 🤗 {/* This is a hugging face emoji */}
- {' '}
-
-
- ) : (
-
- )}
+ {renderNextButton()}
);
}
@@ -65,6 +111,7 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
+ goToCourseExitPage: PropTypes.func.isRequired,
};
UnitNavigation.defaultProps = {
diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js
index 00022c80..917a7b7f 100644
--- a/src/courseware/data/api.js
+++ b/src/courseware/data/api.js
@@ -135,6 +135,10 @@ function normalizeMetadata(metadata) {
notes: camelCaseObject(metadata.notes),
marketingUrl: metadata.marketing_url,
celebrations: camelCaseObject(metadata.celebrations),
+ userHasPassingGrade: metadata.user_has_passing_grade,
+ courseExitPageIsActive: metadata.course_exit_page_is_active,
+ certificateData: camelCaseObject(metadata.certificate_data),
+ verifyIdentityUrl: metadata.verify_identity_url,
};
}
diff --git a/src/index.jsx b/src/index.jsx
index 81d23ad1..f4251c31 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -19,6 +19,7 @@ import { UserMessagesProvider } from './generic/user-messages';
import './index.scss';
import './assets/favicon.ico';
import OutlineTab from './course-home/outline-tab';
+import CourseExit from './courseware/course/course-exit/CourseExit';
import CoursewareContainer from './courseware';
import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandingPage';
import DatesTab from './course-home/dates-tab';
@@ -26,6 +27,7 @@ import ProgressTab from './course-home/progress-tab/ProgressTab';
import { TabContainer } from './tab-page';
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
+import { fetchCourse } from './courseware/data';
import initializeStore from './store';
subscribe(APP_READY, () => {
@@ -49,6 +51,11 @@ subscribe(APP_READY, () => {
+
+
+
+
+
{
+ setDidMount(true);
+ return () => setDidMount(false);
+ }, []);
+
const {
courseId,
unitId,
@@ -51,6 +57,10 @@ export default function InstructorToolbar(props) {
});
const urlStudio = getStudioUrl(courseId, unitId);
const [masqueradeErrorMessage, showMasqueradeError] = useState(null);
+
+ if (!didMount) {
+ return null;
+ }
return (
diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx
index 1255a573..d8dd78b8 100644
--- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx
+++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx
@@ -16,6 +16,8 @@ import {
import messages from './messages';
class MasqueradeWidget extends Component {
+ isMounted = false;
+
constructor(props) {
super(props);
this.courseId = props.courseId;
@@ -29,8 +31,9 @@ class MasqueradeWidget extends Component {
}
componentDidMount() {
+ this.isMounted = true;
getMasqueradeOptions(this.courseId).then((data) => {
- if (data.success) {
+ if (data.success && this.isMounted) {
this.onSuccess(data);
} else {
// This was explicitly denied by the backend;
@@ -47,6 +50,10 @@ class MasqueradeWidget extends Component {
});
}
+ componentWillUnmount() {
+ this.isMounted = false;
+ }
+
onError(message) {
this.props.onError(message);
}
diff --git a/src/tab-page/TabContainer.jsx b/src/tab-page/TabContainer.jsx
index 3094d816..2de5368c 100644
--- a/src/tab-page/TabContainer.jsx
+++ b/src/tab-page/TabContainer.jsx
@@ -10,6 +10,7 @@ export default function TabContainer(props) {
children,
fetch,
tab,
+ isExitPage,
} = props;
const { courseId: courseIdFromUrl } = useParams();
@@ -21,10 +22,11 @@ export default function TabContainer(props) {
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
+ const slice = isExitPage ? 'courseware' : 'courseHome';
const {
courseId,
courseStatus,
- } = useSelector(state => state.courseHome);
+ } = useSelector(state => state[slice]);
return (