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 = {