feat: Create three day streak celebration (#354)

Show learners a celebratory modal if they visit the learning mfe for 3 days in a row. Call edx-platform API to determine if they should see the celebration.
AA-304
This commit is contained in:
Matthew Piatetsky
2021-02-22 14:34:28 -05:00
committed by GitHub
parent 26a7b3b0de
commit 2525805aac
29 changed files with 2290 additions and 2902 deletions

4
src/shared/README.md Normal file
View File

@@ -0,0 +1,4 @@
## Shared module (src/shared)
This module is a place for shared code that is specific to the frontend-app-learning micro-frontend.
If the code is more generic and could be extracted into a reusable code repository like Paragon in the future, then it belongs in the src/generic module.

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Lightbulb } from '@edx/paragon/icons';
import { Icon, Modal } from '@edx/paragon';
import { layoutGenerator } from 'react-break';
import { useDispatch } from 'react-redux';
import { useModel } from '../../generic/model-store';
import StreakMobileImage from './assets/Streak_mobile.png';
import StreakDesktopImage from './assets/Streak_desktop.png';
import messages from './messages';
import { recordModalClosing, recordStreakCelebration } from './utils';
function getRandomFactoid(intl, streakLength) {
const boldedSectionA = intl.formatMessage(messages.streakFactoidABoldedSection);
const boldedSectionB = intl.formatMessage(messages.streakFactoidBBoldedSection);
const factoids = [
(<FormattedMessage
id="learning.streakcelebration.factoida"
defaultMessage="Users who learn {streak_length} days in a row {bolded_section} than those who dont."
values={{
bolded_section: (<b>{boldedSectionA}</b>),
streak_length: (streakLength),
}}
/>),
(<FormattedMessage
id="learning.streakcelebration.factoidb"
defaultMessage="Users who learn {streak_length} days in a row {bolded_section} vs. those who dont."
values={{
bolded_section: (<b>{boldedSectionB}</b>),
streak_length: (streakLength),
}}
/>),
];
return factoids[Math.floor(Math.random() * (factoids.length))];
}
function StreakModal({
courseId, metadataModel, streakLengthToCelebrate, intl, open, ...rest
}) {
const { org, celebrations } = useModel(metadataModel, courseId);
const factoid = getRandomFactoid(intl, streakLengthToCelebrate);
// eslint-disable-next-line no-unused-vars
const [randomFactoid, setRandomFactoid] = useState(factoid); // Don't change factoid on re-render
const layout = layoutGenerator({
mobile: 0,
desktop: 575,
});
const OnMobile = layout.is('mobile');
const OnDesktop = layout.isAtLeast('desktop');
const dispatch = useDispatch();
useEffect(() => {
if (open) {
recordStreakCelebration(org, courseId);
}
}, [open, org, courseId]);
function CloseText() {
return (
<span>
{intl.formatMessage(messages.streakButton)}
<span className="sr-only">. {intl.formatMessage(messages.streakButtonSrOnly)}</span>
</span>
);
}
return (
<div>
<Modal
dialogClassName="streak-modal modal-dialog-centered"
body={(
<>
<p>{intl.formatMessage(messages.streakBody)}</p>
<p className="modal-image">
<OnMobile>
<img src={StreakMobileImage} alt="" className="img-fluid" />
</OnMobile>
<OnDesktop>
<img src={StreakDesktopImage} alt="" className="img-fluid" />
</OnDesktop>
</p>
<div className="row mt-3 mx-3 py-3 bg-light-300">
<Icon className="col-small ml-3" src={Lightbulb} />
<div className="col-11 factoid-wrapper">
{randomFactoid}
</div>
</div>
</>
)}
closeText={<CloseText />}
onClose={() => {
recordModalClosing(metadataModel, celebrations, org, courseId, dispatch);
}}
open={open}
title={`${streakLengthToCelebrate} ${intl.formatMessage(messages.streakHeader)}`}
{...rest}
/>
</div>
);
}
StreakModal.defaultProps = {
open: false,
};
StreakModal.propTypes = {
courseId: PropTypes.string.isRequired,
metadataModel: PropTypes.string.isRequired,
streakLengthToCelebrate: PropTypes.number.isRequired,
intl: intlShape.isRequired,
open: PropTypes.bool,
};
export default injectIntl(StreakModal);

View File

@@ -0,0 +1,67 @@
.streak-modal {
text-align: center;
.modal-header {
padding-bottom: 0;
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: none;
}
}
.modal-title {
padding-top: 1.25rem;
}
.modal-body {
padding-top: .5rem;
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%;
}
}
.modal-image {
margin-top: 1.875rem;
margin-bottom: 1.875rem;
}
.factoid-wrapper {
font-size: .875rem;
text-align: left;
max-width: 85%;
}
}
@media screen and (min-width: 570px) {
.streak-modal {
width: 33.25rem;
max-width: 33.25rem;
.calendar {
margin-top: 2.5rem;
margin-bottom: 2.5rem;
}
.factoid-wrapper {
max-width: 90%;
}
}
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Factory } from 'rosie';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { initializeTestStore, render, screen } from '../../setupTest';
import StreakModal from './StreakCelebrationModal';
jest.mock('@edx/frontend-platform/analytics');
describe('Loaded Tab Page', () => {
const mockData = { metadataModel: 'coursewareMeta' };
beforeAll(async () => {
mockData.open = true;
mockData.streakLengthToCelebrate = 3;
});
it('shows streak celebration modal', async () => {
const courseMetadata = Factory.build('courseMetadata', { celebrations: { shouldCelebrateStreak: true } });
mockData.courseId = courseMetadata.id;
const testStore = await initializeTestStore({ courseMetadata }, false);
render(<StreakModal {...mockData} courseId={courseMetadata.id} />, { store: testStore });
await screen.findByText('3 day streak');
await screen.findByText('Keep it up, youre on a roll!');
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.celebration.streak.opened', {
org_key: courseMetadata.org,
courserun_key: mockData.courseId,
is_staff: false,
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
export { default } from './StreakCelebrationModal';

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
streakHeader: {
id: 'learning.streakCelebration.header',
defaultMessage: 'day streak',
description: 'Will come after a number. For example, 3 day streak',
},
streakBody: {
id: 'learning.streakCelebration.body',
defaultMessage: 'Keep it up, youre on a roll!',
},
streakButton: {
id: 'learning.streakCelebration.button',
defaultMessage: 'Keep it up',
},
streakButtonSrOnly: {
id: 'learning.streakCelebration.buttonSrOnly',
defaultMessage: 'Close modal and continue',
description: 'Screenreader label for streakButton text',
},
streakFactoidABoldedSection: {
id: 'learning.streakCelebration.factoidABoldedSection',
defaultMessage: 'are 20x more likely to pass their course',
description: 'This bolded section is in the following sentence: Users who learn 3 days in a row {bolded_section} than those who don\'t.',
},
streakFactoidBBoldedSection: {
id: 'learning.streakCelebration.factoidBBoldedSection',
defaultMessage: 'complete 5x as much course content on average',
description: 'This bolded section is in the following sentence: Users who learn 3 days in a row {bolded_section} vs. those who don\'t.',
},
});
export default messages;

View File

@@ -0,0 +1,27 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { updateModel } from '../../generic/model-store';
function recordStreakCelebration(org, courseId) {
// Tell our analytics
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.ui.lms.celebration.streak.opened', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
});
}
function recordModalClosing(metadataModel, celebrations, org, courseId, dispatch) {
// Ensure we only celebrate each streak once
dispatch(updateModel({
modelType: metadataModel,
model: {
id: courseId,
celebrations: { ...celebrations, shouldCelebrateStreak: false },
},
}));
}
export { recordStreakCelebration, recordModalClosing };