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 (