diff --git a/.env b/.env
index 3c0d416b..65f9e074 100644
--- a/.env
+++ b/.env
@@ -14,5 +14,6 @@ ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
+TWITTER_URL=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null
diff --git a/.env.development b/.env.development
index 2bc0c1c1..fe0e8634 100644
--- a/.env.development
+++ b/.env.development
@@ -14,5 +14,6 @@ PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
+TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'
diff --git a/.env.test b/.env.test
index 3fae7f5b..60d5faab 100644
--- a/.env.test
+++ b/.env.test
@@ -14,5 +14,6 @@ PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
+TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'
diff --git a/package-lock.json b/package-lock.json
index 76159a55..8a6308c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5196,6 +5196,11 @@
}
}
},
+ "breakjs": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/breakjs/-/breakjs-1.0.0.tgz",
+ "integrity": "sha1-7INToGhi60OWLergkHLuZqTNhFk="
+ },
"brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -12330,6 +12335,24 @@
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
"dev": true
},
+ "jsonp": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz",
+ "integrity": "sha1-pltPoPEL2nGaBUQep7lMVfPhW64=",
+ "requires": {
+ "debug": "^2.1.3"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@@ -15565,6 +15588,16 @@
"prop-types": "^15.6.2"
}
},
+ "react-break": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/react-break/-/react-break-1.3.2.tgz",
+ "integrity": "sha512-gm5paFth+ac+Ag35l1X0/V/XmdQn+Y+YggNInqaVXGHBrsODCBu8aXQpOsilYl+MfY6TL3eCJpkwuX1FVhDcpg==",
+ "requires": {
+ "babel-runtime": "^6.10.0",
+ "breakjs": "^1.0.0",
+ "prop-types": "^15.6.0"
+ }
+ },
"react-dev-utils": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-9.0.3.tgz",
@@ -15806,6 +15839,15 @@
"tiny-warning": "^1.0.0"
}
},
+ "react-share": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/react-share/-/react-share-4.1.0.tgz",
+ "integrity": "sha512-4/XqVBC+hreniU08zkzAkOGC3FPdJhcLzt1QCdybsZPd5ieUS++iChEEptvNoksiJ5NxbI2VRoDY9ovLAGl7GQ==",
+ "requires": {
+ "classnames": "^2.2.5",
+ "jsonp": "^0.2.1"
+ }
+ },
"react-transition-group": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz",
diff --git a/package.json b/package.json
index cef7c10d..73773f7d 100644
--- a/package.json
+++ b/package.json
@@ -48,10 +48,12 @@
"core-js": "^3.6.2",
"prop-types": "^15.7.2",
"react": "^16.12.0",
+ "react-break": "^1.3.2",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
+ "react-share": "^4.1.0",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
},
diff --git a/public/index.html b/public/index.html
index 3f8576ac..c022b7f0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,7 +1,7 @@
- Course | edX
+ Course | <%= process.env.SITE_NAME %>
diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx
index 93d3376d..0f3405bc 100644
--- a/src/courseware/CoursewareContainer.jsx
+++ b/src/courseware/CoursewareContainer.jsx
@@ -19,6 +19,7 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
+import { handleNextSectionCelebration } from './course/celebration';
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
@@ -52,6 +53,8 @@ function useNextSequence(sequenceId) {
function useNextSequenceHandler(courseId, sequenceId) {
+ const course = useModel('courses', courseId);
+ const sequence = useModel('sequences', sequenceId);
const nextSequence = useNextSequence(sequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -64,6 +67,11 @@ function useNextSequenceHandler(courseId, sequenceId) {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${nextSequence.id}`);
}
+
+ const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
+ if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
+ handleNextSectionCelebration(sequenceId, nextSequence.id);
+ }
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx
index ffc7a8b7..1c58aecd 100644
--- a/src/courseware/course/Course.jsx
+++ b/src/courseware/course/Course.jsx
@@ -7,6 +7,7 @@ import { useOfferAlert } from '../../alerts/offer-alert';
import Sequence from './sequence';
+import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from './course-sock';
import ContentTools from './content-tools';
@@ -39,9 +40,13 @@ function Course({
const {
canShowUpgradeSock,
+ celebrations,
verifiedMode,
} = course;
+ const celebrateFirstSection = celebrations && celebrations.firstSection;
+ const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection);
+
return (
<>
+ {celebrationOpen && (
+
+ )}
{canShowUpgradeSock && verifiedMode && }
>
diff --git a/src/courseware/course/celebration/CelebrationModal.jsx b/src/courseware/course/celebration/CelebrationModal.jsx
new file mode 100644
index 00000000..78c752ad
--- /dev/null
+++ b/src/courseware/course/celebration/CelebrationModal.jsx
@@ -0,0 +1,68 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { Modal } from '@edx/paragon';
+import { layoutGenerator } from 'react-break';
+
+import ClapsMobile from './assets/claps_280x201.gif';
+import ClapsTablet from './assets/claps_456x328.gif';
+import messages from './messages';
+import SocialIcons from './SocialIcons';
+import { recordFirstSectionCelebration } from './utils';
+
+import './CelebrationModal.scss';
+
+function CelebrationModal({
+ courseId, intl, open, ...rest
+}) {
+ const layout = layoutGenerator({
+ mobile: 0,
+ tablet: 400,
+ });
+
+ const OnMobile = layout.is('mobile');
+ const OnAtLeastTablet = layout.isAtLeast('tablet');
+
+ useEffect(() => {
+ if (open) {
+ recordFirstSectionCelebration(courseId);
+ }
+ }, [open]);
+
+ return (
+
+ {intl.formatMessage(messages.completed)}
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.earned)} {intl.formatMessage(messages.share)}
+
+
+ >
+ )}
+ closeText={intl.formatMessage(messages.forward)}
+ onClose={() => {}} // Don't do anything special, just having the modal close is enough (this is a required prop)
+ open={open}
+ title={intl.formatMessage(messages.congrats)}
+ {...rest}
+ />
+ );
+}
+
+CelebrationModal.defaultProps = {
+ open: false,
+};
+
+CelebrationModal.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+ open: PropTypes.bool,
+};
+
+export default injectIntl(CelebrationModal);
diff --git a/src/courseware/course/celebration/CelebrationModal.scss b/src/courseware/course/celebration/CelebrationModal.scss
new file mode 100644
index 00000000..ef23832f
--- /dev/null
+++ b/src/courseware/course/celebration/CelebrationModal.scss
@@ -0,0 +1,35 @@
+@import '~@edx/paragon/scss/edx/theme.scss';
+
+.modal {
+ text-align: center;
+
+ .modal-header {
+ border-bottom: 0; // override default hr line
+ justify-content: center;
+
+ button {
+ // This lets us center the modal title at full width, without taking button width into account
+ position: absolute;
+ right: 1rem;
+ }
+
+ button::after {
+ content: "⨉";
+ }
+ }
+
+ .modal-body {
+ font-size: 1.2rem;
+ }
+
+ .modal-footer {
+ border-top: 0; // override default hr line
+ justify-content: center;
+
+ button {
+ @extend .btn-primary;
+ font-size: 1.2rem;
+ width: 50%;
+ }
+ }
+}
diff --git a/src/courseware/course/celebration/SocialIcons.jsx b/src/courseware/course/celebration/SocialIcons.jsx
new file mode 100644
index 00000000..575d244a
--- /dev/null
+++ b/src/courseware/course/celebration/SocialIcons.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ FacebookIcon,
+ FacebookShareButton,
+ TwitterIcon,
+ TwitterShareButton,
+} from 'react-share';
+
+import { getConfig } from '@edx/frontend-platform';
+import { sendTrackEvent } from '@edx/frontend-platform/analytics';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+import { useModel } from '../../../model-store';
+
+function SocialIcons({ courseId, intl }) {
+ const {
+ marketingUrl,
+ title,
+ } = useModel('courses', courseId);
+
+ if (!marketingUrl) {
+ return null;
+ }
+
+ const twitterUrl = getConfig().TWITTER_URL;
+ const twitterAccount = twitterUrl && twitterUrl.substring(twitterUrl.lastIndexOf('/') + 1);
+
+ const logClick = (service) => {
+ const { administrator } = getAuthenticatedUser();
+ sendTrackEvent('edx.ui.lms.celebration.social_share.clicked', {
+ course_id: courseId,
+ is_staff: administrator,
+ service,
+ });
+ };
+
+ return (
+
+ { twitterAccount && (
+ logClick('twitter')}
+ hashtags={['mooc']}
+ title={intl.formatMessage(messages.social, { platform: `@${twitterAccount}`, title })}
+ url={marketingUrl}
+ >
+
+ {intl.formatMessage(messages.shareService, { service: 'Twitter' })}
+
+ )}
+ logClick('facebook')}
+ className="ml-2"
+ quote={intl.formatMessage(messages.social, { platform: getConfig().SITE_NAME, title })}
+ url={marketingUrl}
+ >
+
+ {intl.formatMessage(messages.shareService, { service: 'Facebook' })}
+
+
+ );
+}
+
+SocialIcons.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(SocialIcons);
diff --git a/src/courseware/course/celebration/assets/claps_280x201.gif b/src/courseware/course/celebration/assets/claps_280x201.gif
new file mode 100644
index 00000000..cab4cd01
Binary files /dev/null and b/src/courseware/course/celebration/assets/claps_280x201.gif differ
diff --git a/src/courseware/course/celebration/assets/claps_456x328.gif b/src/courseware/course/celebration/assets/claps_456x328.gif
new file mode 100644
index 00000000..5a587101
Binary files /dev/null and b/src/courseware/course/celebration/assets/claps_456x328.gif differ
diff --git a/src/courseware/course/celebration/index.js b/src/courseware/course/celebration/index.js
new file mode 100644
index 00000000..015f7d43
--- /dev/null
+++ b/src/courseware/course/celebration/index.js
@@ -0,0 +1,2 @@
+export { default as CelebrationModal } from './CelebrationModal';
+export { handleNextSectionCelebration, shouldCelebrateOnSectionLoad } from './utils';
diff --git a/src/courseware/course/celebration/messages.js b/src/courseware/course/celebration/messages.js
new file mode 100644
index 00000000..05b25c9d
--- /dev/null
+++ b/src/courseware/course/celebration/messages.js
@@ -0,0 +1,36 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ completed: {
+ id: 'learning.celebration.completed',
+ defaultMessage: 'You just completed the first section of your course.',
+ },
+ congrats: {
+ id: 'learning.celebration.congrats',
+ defaultMessage: 'Congratulations!',
+ },
+ earned: {
+ id: 'learning.celebration.earned',
+ defaultMessage: 'You earned it!',
+ },
+ forward: {
+ id: 'learning.celebration.forward',
+ defaultMessage: 'Keep going',
+ description: 'Button to close celebration dialog and get back to course',
+ },
+ share: {
+ id: 'learning.celebration.share',
+ defaultMessage: 'Take a moment to celebrate and share your progress.',
+ },
+ shareService: {
+ id: 'learning.celebration.share.service',
+ defaultMessage: 'Share your progress on {service}.',
+ },
+ social: {
+ id: 'learning.celebration.social',
+ defaultMessage: 'I’m on my way to completing {title} online with {platform}. What are you spending your time learning?',
+ description: 'Shown when sharing course progress on a social network',
+ },
+});
+
+export default messages;
diff --git a/src/courseware/course/celebration/utils.jsx b/src/courseware/course/celebration/utils.jsx
new file mode 100644
index 00000000..65c0fe55
--- /dev/null
+++ b/src/courseware/course/celebration/utils.jsx
@@ -0,0 +1,51 @@
+import { sendTrackEvent } from '@edx/frontend-platform/analytics';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+
+import { setFirstSectionCelebrationComplete } from '../../../data/api';
+import { clearLocalStorage, getLocalStorage, setLocalStorage } from '../../../data/localStorage';
+
+const CELEBRATION_LOCAL_STORAGE_KEY = 'CelebrationModal.showOnSectionLoad';
+
+// Records clicks through the end of a section, so that we can know whether we should celebrate when we finish loading
+function handleNextSectionCelebration(sequenceId, nextSequenceId) {
+ setLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY, {
+ prevSequenceId: sequenceId,
+ nextSequenceId,
+ });
+}
+
+function recordFirstSectionCelebration(courseId) {
+ // Tell the LMS
+ setFirstSectionCelebrationComplete(courseId);
+
+ // Tell our analytics
+ const { administrator } = getAuthenticatedUser();
+ sendTrackEvent('edx.ui.lms.celebration.first_section.opened', {
+ course_id: courseId,
+ is_staff: administrator,
+ });
+}
+
+// Looks at local storage to see whether we just came from the end of a section.
+// Note! This does have side effects (will clear some local storage and may start an api call).
+function shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection) {
+ const celebrationIds = getLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY);
+ if (!celebrationIds) {
+ return false;
+ }
+
+ const {
+ prevSequenceId,
+ nextSequenceId,
+ } = celebrationIds;
+ const shouldCelebrate = sequenceId === nextSequenceId && celebrateFirstSection;
+
+ if (sequenceId !== prevSequenceId && sequenceId !== nextSequenceId) {
+ // Don't clear until we move off of current/prev sequence
+ clearLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY);
+ }
+
+ return shouldCelebrate;
+}
+
+export { handleNextSectionCelebration, recordFirstSectionCelebration, shouldCelebrateOnSectionLoad };
diff --git a/src/data/api.js b/src/data/api.js
index 38ad54f6..0f027d9c 100644
--- a/src/data/api.js
+++ b/src/data/api.js
@@ -37,6 +37,8 @@ function normalizeMetadata(metadata) {
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
showCalculator: metadata.show_calculator,
notes: camelCaseObject(metadata.notes),
+ marketingUrl: metadata.marketing_url,
+ celebrations: camelCaseObject(metadata.celebrations),
};
}
@@ -227,6 +229,14 @@ export async function getResumeBlock(courseId) {
return camelCaseObject(data);
}
+// Does not block on answer
+export function setFirstSectionCelebrationComplete(courseId) {
+ const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/celebration/${courseId}`);
+ getAuthenticatedHttpClient().post(url.href, {
+ first_section: false,
+ });
+}
+
export async function updateCourseDeadlines(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
diff --git a/src/data/localStorage.js b/src/data/localStorage.js
new file mode 100644
index 00000000..ce3e38da
--- /dev/null
+++ b/src/data/localStorage.js
@@ -0,0 +1,51 @@
+// This file holds some convenience methods for dealing with localStorage.
+//
+// NOTE: These storage keys are not namespaced. That means that it's shared for the current fully
+// qualified domain. Namespacing could be added, but we'll cross that bridge when we need it.
+
+function getLocalStorage(key) {
+ try {
+ if (global.localStorage) {
+ const rawItem = global.localStorage.getItem(key);
+ if (rawItem) {
+ return JSON.parse(rawItem);
+ }
+ }
+ } catch (e) {
+ // If this fails for some reason, just return null.
+ }
+ return null;
+}
+
+function setLocalStorage(key, value) {
+ try {
+ if (global.localStorage) {
+ global.localStorage.setItem(key, JSON.stringify(value));
+ }
+ } catch (e) {
+ // If this fails, just bail.
+ }
+}
+
+function clearLocalStorage(key) {
+ try {
+ if (global.localStorage) {
+ global.localStorage.removeItem(key);
+ }
+ } catch (e) {
+ // If this fails, just bail.
+ }
+}
+
+function popLocalStorage(key) {
+ const value = getLocalStorage(key);
+ clearLocalStorage(key);
+ return value;
+}
+
+export {
+ clearLocalStorage,
+ getLocalStorage,
+ popLocalStorage,
+ setLocalStorage,
+};
diff --git a/src/index.jsx b/src/index.jsx
index 1eb6233d..7d6d68f2 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -69,6 +69,7 @@ initialize({
mergeConfig({
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
+ TWITTER_URL: process.env.TWITTER_URL || null,
}, 'LearnerAppConfig');
},
},
diff --git a/src/user-messages/UserMessagesProvider.jsx b/src/user-messages/UserMessagesProvider.jsx
index 3777dddb..d1d9eda0 100644
--- a/src/user-messages/UserMessagesProvider.jsx
+++ b/src/user-messages/UserMessagesProvider.jsx
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
+import { getLocalStorage, popLocalStorage, setLocalStorage } from '../data/localStorage';
export const ALERT_TYPES = {
ERROR: 'error',
@@ -10,50 +11,19 @@ export const ALERT_TYPES = {
INFO: 'info',
};
-// NOTE: This storage key is not namespaced. That means that it's shared for the current fully
-// qualified domain. Namespacing could be added by adding an optional prop to UserMessagesProvider
-// to set a namespace, but we'll cross that bridge when we need it.
const FLASH_MESSAGES_LOCAL_STORAGE_KEY = 'UserMessagesProvider.flashMessages';
-function getFlashMessages() {
- let flashMessages = [];
- try {
- if (global.localStorage) {
- const rawItem = global.localStorage.getItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
- if (rawItem) {
- // Only try to parse and set flashMessages from the raw item if it exists.
- const parsed = JSON.parse(rawItem);
- if (Array.isArray(parsed)) {
- flashMessages = parsed;
- }
- }
- }
- } catch (e) {
- // If this fails for some reason, just return the empty array.
- }
- return flashMessages;
-}
-
function addFlashMessage(message) {
- try {
- if (global.localStorage) {
- const flashMessages = getFlashMessages();
- flashMessages.push(message);
- global.localStorage.setItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY, JSON.stringify(flashMessages));
- }
- } catch (e) {
- // If this fails, just bail.
+ let flashMessages = getLocalStorage(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
+ if (!flashMessages || !Array.isArray(flashMessages)) {
+ flashMessages = [];
}
+ flashMessages.push(message);
+ setLocalStorage(FLASH_MESSAGES_LOCAL_STORAGE_KEY, flashMessages);
}
-function clearFlashMessages() {
- try {
- if (global.localStorage) {
- global.localStorage.removeItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
- }
- } catch (e) {
- // If this fails, just bail.
- }
+function popFlashMessages() {
+ return popLocalStorage(FLASH_MESSAGES_LOCAL_STORAGE_KEY) || [];
}
export default function UserMessagesProvider({ children }) {
@@ -103,12 +73,11 @@ export default function UserMessagesProvider({ children }) {
}
useEffect(() => {
- const flashMessages = getFlashMessages();
- flashMessages.forEach(flashMessage => add(flashMessage));
// We only allow flash messages to persist through one refresh, then we clear them out.
// If we want persistent messages, then add a 'persist' key to the messages and handle that
// as a separate local storage item.
- clearFlashMessages();
+ const flashMessages = popFlashMessages();
+ flashMessages.forEach(flashMessage => add(flashMessage));
}, []);
const value = {