AA-137: Add first-section celebration (#78)

When a learner completes their first section in a course, throw up
a modal that celebrates that fact and encourages them to share
progress.
This commit is contained in:
Michael Terry
2020-06-18 09:27:11 -04:00
committed by GitHub
parent eb8c97ee86
commit 6cdd075243
20 changed files with 402 additions and 42 deletions

1
.env
View File

@@ -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

View File

@@ -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'

View File

@@ -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'

42
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course | edX</title>
<title>Course | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />

View File

@@ -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]);
}

View File

@@ -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 (
<>
<AlertList
@@ -73,6 +78,12 @@ function Course({
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{celebrationOpen && (
<CelebrationModal
courseId={courseId}
open
/>
)}
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>

View File

@@ -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 (
<Modal
body={(
<>
<p>{intl.formatMessage(messages.completed)}</p>
<OnMobile>
<img src={ClapsMobile} alt="" className="img-fluid" />
</OnMobile>
<OnAtLeastTablet>
<img src={ClapsTablet} alt="" className="img-fluid w-100" />
</OnAtLeastTablet>
<p className="mt-3">
<strong>{intl.formatMessage(messages.earned)}</strong> {intl.formatMessage(messages.share)}
</p>
<SocialIcons courseId={courseId} />
</>
)}
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);

View File

@@ -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%;
}
}
}

View File

@@ -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 (
<div className="social-icons">
{ twitterAccount && (
<TwitterShareButton
beforeOnClick={() => logClick('twitter')}
hashtags={['mooc']}
title={intl.formatMessage(messages.social, { platform: `@${twitterAccount}`, title })}
url={marketingUrl}
>
<TwitterIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareService, { service: 'Twitter' })}</span>
</TwitterShareButton>
)}
<FacebookShareButton
beforeOnClick={() => logClick('facebook')}
className="ml-2"
quote={intl.formatMessage(messages.social, { platform: getConfig().SITE_NAME, title })}
url={marketingUrl}
>
<FacebookIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareService, { service: 'Facebook' })}</span>
</FacebookShareButton>
</div>
);
}
SocialIcons.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SocialIcons);

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,2 @@
export { default as CelebrationModal } from './CelebrationModal';
export { handleNextSectionCelebration, shouldCelebrateOnSectionLoad } from './utils';

View File

@@ -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: 'Im 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;

View File

@@ -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 };

View File

@@ -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 });

51
src/data/localStorage.js Normal file
View File

@@ -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,
};

View File

@@ -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');
},
},

View File

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