@@ -84,8 +78,22 @@ DateSummary.propTypes = {
learnerHasAccess: PropTypes.bool,
}).isRequired,
userTimezone: PropTypes.string,
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ upgradeDeadline: PropTypes.string,
+ }),
+ }),
};
DateSummary.defaultProps = {
userTimezone: null,
+ /** [MM-P2P] Experiment */
+ mmp2p: {
+ state: {
+ isEnabled: false,
+ upgradeDeadline: '',
+ },
+ },
};
diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx
index 64416731..086a3723 100644
--- a/src/course-home/outline-tab/OutlineTab.jsx
+++ b/src/course-home/outline-tab/OutlineTab.jsx
@@ -28,6 +28,9 @@ import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
+/** [MM-P2P] Experiment */
+import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
+
function OutlineTab({ intl }) {
const {
courseId,
@@ -88,6 +91,9 @@ function OutlineTab({ intl }) {
const courseSock = useRef(null);
+ /** [[MM-P2P] Experiment */
+ const MMP2P = initHomeMMP2P(courseId);
+
return (
<>
)}
-
+ {/** [MM-P2P] Experiment (className for optimizely trigger) */}
+
-
+ { /** [MM-P2P] Experiment (the conditional) */ }
+ { !MMP2P.state.isEnabled
+ && (
+
+ )}
{courseDateBlocks && (
)}
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
@@ -189,12 +202,21 @@ function OutlineTab({ intl }) {
-
{ courseSock.current.showToUser(); } : null}
- />
+ { /** [MM-P2P] Experiment (conditional) */ }
+ { MMP2P.state.isEnabled
+ ?
+ : (
+ { courseSock.current.showToUser(); } : null
+ }
+ />
+ )}
))}
@@ -42,10 +49,14 @@ function CourseDates({ courseId, intl }) {
CourseDates.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({}),
};
CourseDates.defaultProps = {
courseId: null,
+ /** [MM-P2P] Experiment */
+ mmp2p: {},
};
export default injectIntl(CourseDates);
diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx
index 2ebadcf4..afae9884 100644
--- a/src/courseware/course/Course.jsx
+++ b/src/courseware/course/Course.jsx
@@ -16,6 +16,9 @@ import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from '../../generic/course-sock';
import { useModel } from '../../generic/model-store';
+/** [MM-P2P] Experiment */
+import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
+
function Course({
courseId,
sequenceId,
@@ -83,19 +86,25 @@ function Course({
};
// The above block of code should be reverted after the REV1512 experiment
+ /** [MM-P2P] Experiment */
+ const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
+
return (
<>
{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}
-
+ { /** This conditional is for the [MM-P2P] Experiment */}
+ { !MMP2P.state.isEnabled && (
+
+ )}
{celebrationOpen && (
)}
+ { /** [MM-P2P] Experiment */ }
+ { MMP2P.meta.modalLock && }
>
);
}
diff --git a/src/courseware/course/CourseBreadcrumbs.jsx b/src/courseware/course/CourseBreadcrumbs.jsx
index b6e7b5a6..1111182f 100644
--- a/src/courseware/course/CourseBreadcrumbs.jsx
+++ b/src/courseware/course/CourseBreadcrumbs.jsx
@@ -7,6 +7,9 @@ import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
+/** [MM-P2P] Experiment */
+import { MMP2PFlyoverTrigger } from '../../experiments/mm-p2p';
+
function CourseBreadcrumb({
url, children, withSeparator, ...attrs
}) {
@@ -39,6 +42,8 @@ export default function CourseBreadcrumbs({
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
+ /** [MM-P2P] Experiment */
+ mmp2p,
}) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
@@ -92,20 +97,24 @@ export default function CourseBreadcrumbs({
))}
{/* The below block of code should be reverted after the REV1512 experiment */}
- {REV1512FlyoverEnabled
- && !isMobile && (
- {
- toggleREV1512Flyover();
- }}
- >
-
-
-
-
+ {/** [MM-P2P] Experiment (additional conditional) */}
+ {REV1512FlyoverEnabled && !mmp2p.state.isEnabled && !isMobile && (
+ {
+ toggleREV1512Flyover();
+ }}
+ >
+
+
+
+
+ )}
+ {/** [MM-P2P] Experiment */}
+ {mmp2p.state.isEnabled && (
+
)}
@@ -119,9 +128,19 @@ CourseBreadcrumbs.propTypes = {
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
+
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ }),
};
CourseBreadcrumbs.defaultProps = {
sectionId: null,
sequenceId: null,
+
+ /** [MM-P2P] Experiment */
+ mmp2p: {},
};
diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx
index 75ab27d8..36d21060 100644
--- a/src/courseware/course/sequence/Sequence.jsx
+++ b/src/courseware/course/sequence/Sequence.jsx
@@ -24,6 +24,9 @@ import messages from './messages';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
+/** [MM-P2P] Experiment */
+import { MMP2PFlyover, MMP2PFlyoverMobile } from '../../../experiments/mm-p2p';
+
function REV1512Flyover({ toggleREV1512Flyover }) {
// This component should be reverted after the REV1512 experiment
return (
@@ -192,6 +195,8 @@ function Sequence({
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
+ /** [MM-P2P] Experiment */
+ mmp2p,
}) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
@@ -318,6 +323,9 @@ function Sequence({
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* should be reverted after REV1512 experiment */
+ /** [MM-P2P] Experiment */
+ mmp2p={mmp2p}
+
nextSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
@@ -339,6 +347,8 @@ function Sequence({
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
+ /** [MM-P2P] Experiment */
+ mmp2p={mmp2p}
/>
{unitHasLoaded && (
{/* This block of code should be reverted post REV1512 experiment */}
- {REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
+ {/** [MM-P2P] Experiment (additional conditional) */}
+ {!mmp2p.state.isEnabled && REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
isMobile
?
:
)}
+ {/** [MM-P2P] Experiment */}
+ {(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
+ isMobile
+ ?
+ :
+ )}
@@ -388,11 +405,31 @@ Sequence.propTypes = {
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
+
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ flyover: PropTypes.shape({
+ isVisible: PropTypes.bool.isRequired,
+ }),
+ meta: PropTypes.shape({
+ showLock: PropTypes.bool,
+ }),
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ }),
};
Sequence.defaultProps = {
sequenceId: null,
unitId: null,
+
+ /** [MM-P2P] Experiment */
+ mmp2p: {
+ flyover: { isVisible: false },
+ meta: { showLock: false },
+ state: { isEnabled: false },
+ },
};
export default injectIntl(Sequence);
diff --git a/src/courseware/course/sequence/SequenceContent.jsx b/src/courseware/course/sequence/SequenceContent.jsx
index 51b796b8..d6eff201 100644
--- a/src/courseware/course/sequence/SequenceContent.jsx
+++ b/src/courseware/course/sequence/SequenceContent.jsx
@@ -10,7 +10,14 @@ import Unit from './Unit';
const ContentLock = React.lazy(() => import('./content-lock'));
function SequenceContent({
- gated, intl, courseId, sequenceId, unitId, unitLoadedHandler,
+ gated,
+ intl,
+ courseId,
+ sequenceId,
+ unitId,
+ unitLoadedHandler,
+ /** [MM-P2P] Experiment */
+ mmp2p,
}) {
const sequence = useModel('sequences', sequenceId);
@@ -54,6 +61,8 @@ function SequenceContent({
key={unitId}
id={unitId}
onLoaded={unitLoadedHandler}
+ /** [MM-P2P] Experiment */
+ mmp2p={mmp2p}
/>
);
}
@@ -65,10 +74,28 @@ SequenceContent.propTypes = {
unitId: PropTypes.string,
unitLoadedHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ flyover: PropTypes.shape({
+ isVisible: PropTypes.bool.isRequired,
+ }),
+ meta: PropTypes.shape({
+ showLock: PropTypes.bool,
+ }),
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ }),
};
SequenceContent.defaultProps = {
unitId: null,
+ /** [MM-P2P] Experiment */
+ mmp2p: {
+ flyover: { isVisible: false },
+ meta: { showLock: false },
+ state: { isEnabled: false },
+ },
};
export default injectIntl(SequenceContent);
diff --git a/src/courseware/course/sequence/Unit.jsx b/src/courseware/course/sequence/Unit.jsx
index 78c53bec..74ade1ba 100644
--- a/src/courseware/course/sequence/Unit.jsx
+++ b/src/courseware/course/sequence/Unit.jsx
@@ -18,6 +18,8 @@ import { useModel } from '../../../generic/model-store';
import PageLoading from '../../../generic/PageLoading';
import { processEvent } from '../../../course-home/data/thunks';
import { fetchCourse } from '../../data/thunks';
+/** [MM-P2P] Experiment */
+import { MMP2PLockPaywall } from '../../../experiments/mm-p2p';
const LockPaywall = React.lazy(() => import('./lock-paywall'));
const LockPaywallValuePropExperiment = React.lazy(() => import('./lock-paywall-value-prop'));
@@ -60,6 +62,8 @@ function Unit({
onLoaded,
id,
intl,
+ /** [MM-P2P] Experiment */
+ mmp2p,
}) {
const { authenticatedUser } = useContext(AppContext);
const view = authenticatedUser ? 'student_view' : 'public_view';
@@ -130,7 +134,7 @@ function Unit({
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
- {contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
+ { !mmp2p.state.isEnabled && contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
}
)}
- {!hasLoaded && (
+ { /** [MM-P2P] Experiment */ }
+ { mmp2p.meta.showLock && (
+
+ )}
+ { /** [MM-P2P] Experiment (conditional) */ }
+ {!mmp2p.meta.blockContent && !hasLoaded && (
@@ -173,24 +182,27 @@ function Unit({
dialogClassName="modal-lti"
/>
)}
-
-
+ { /** [MM-P2P] Experiment (conditional) */ }
+ { !mmp2p.meta.blockContent && (
+
+
+ )}
);
}
@@ -201,11 +213,31 @@ Unit.propTypes = {
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLoaded: PropTypes.func,
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ meta: PropTypes.shape({
+ showLock: PropTypes.bool,
+ blockContent: PropTypes.bool,
+ }),
+ }),
};
Unit.defaultProps = {
format: null,
onLoaded: undefined,
+ /** [MM-P2P] Experiment */
+ mmp2p: {
+ state: {
+ isEnabled: false,
+ },
+ meta: {
+ showLock: false,
+ blockContent: false,
+ },
+ },
};
export default injectIntl(Unit);
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
index 467737c8..67aa7159 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx
@@ -15,6 +15,8 @@ import { useModel } from '../../../../generic/model-store';
import { LOADED } from '../../../data/slice';
import messages from './messages';
+/** [MM-P2P] Experiment */
+import { MMP2PFlyoverTriggerMobile } from '../../../../experiments/mm-p2p';
function SequenceNavigation({
intl,
@@ -28,6 +30,8 @@ function SequenceNavigation({
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
+ /** [MM-P2P] Experiment */
+ mmp2p,
}) {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
@@ -87,7 +91,8 @@ function SequenceNavigation({
{renderUnitButtons()}
{renderNextButton()}
- {REV1512FlyoverEnabled
+ {/** [MM-P2P] Experiment (additional conditional) */}
+ {!mmp2p.state.isEnabled && REV1512FlyoverEnabled
&& isMobile && (
)}
+ {/** [MM-P2P] Experiment */}
+ { mmp2p.state.isEnabled &&
}
);
@@ -119,11 +126,23 @@ SequenceNavigation.propTypes = {
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
+
+ /** [MM-P2P] Experiment */
+ mmp2p: PropTypes.shape({
+ state: PropTypes.shape({
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ }),
};
SequenceNavigation.defaultProps = {
className: null,
unitId: null,
+
+ /** [MM-P2P] Experiment */
+ mmp2p: {
+ state: { isEnabled: false },
+ },
};
export default injectIntl(SequenceNavigation);
diff --git a/src/experiments/mm-p2p/BlockModal.jsx b/src/experiments/mm-p2p/BlockModal.jsx
new file mode 100644
index 00000000..8fd1d8de
--- /dev/null
+++ b/src/experiments/mm-p2p/BlockModal.jsx
@@ -0,0 +1,21 @@
+import React, { Suspense } from 'react';
+
+import { ModalLayer } from '@edx/paragon';
+
+import PageLoading from '../../generic/PageLoading';
+
+const BlockModalContent = React.lazy(() => import('./BlockModalContent'));
+
+export const BlockModal = () => (
+
{}}
+ isBlocking
+ >
+ )}>
+
+
+
+);
+
+export default BlockModal;
diff --git a/src/experiments/mm-p2p/BlockModal.scss b/src/experiments/mm-p2p/BlockModal.scss
new file mode 100644
index 00000000..dc1f8b63
--- /dev/null
+++ b/src/experiments/mm-p2p/BlockModal.scss
@@ -0,0 +1,43 @@
+.mmp2p-modal-dialog.modal-content {
+ padding: 15px;
+ border-radius: 0px;
+ .mmp2p-block-modal-wrapper {
+ background-color: white;
+ text-align: left;
+ .bullet-list-item {
+ font-size: 18px;
+ line-height: 28px;
+ font-weight: 400;
+ &:not(:last-child) {
+ margin-bottom: 20px;
+ }
+ color: $gray-900;
+ .icon-container {
+ vertical-align: top;
+ display: inline-block;
+ margin-right: 7px;
+ }
+ .bullet-item-content {
+ display: inline-block;
+ width: calc(100% - 245px);
+ }
+ }
+ .subheader {
+ font-size: 18px;
+ line-height: 28px;
+ font-weight: 500;
+ margin-bottom: 15px;
+ }
+ img.certificate-image {
+ position: absolute;
+ top: 32px;
+ right: 32px;
+ width: 184px;
+ }
+ #mmp2p-modal-explore-btn {
+ float: right;
+ margin-bottom: 16px;
+ margin-right: 16px;
+ }
+ }
+}
diff --git a/src/experiments/mm-p2p/BlockModalContent.jsx b/src/experiments/mm-p2p/BlockModalContent.jsx
new file mode 100644
index 00000000..6ff2f7d9
--- /dev/null
+++ b/src/experiments/mm-p2p/BlockModalContent.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, ModalLayer } from '@edx/paragon';
+import CertImage from '../../generic/assets/edX_certificate.png';
+
+const BulletList = ({ children }) => (
+
+);
+BulletList.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export const BlockModal = () => (
+
{}}
+ isBlocking
+ >
+
+
+
+ Deadline to access full course has passed
+
+
+ What does the Verified Track get you?
+
+
+
+
+ Earn a verified certificate of completion to showcase on your resume
+
+
+ Unlock unlimited access to all course content and activities,
+ including graded assignments, even after the course ends.
+
+
+ Support our non-profit mission at edx
+
+
+
+
+
+
+ Explore more courses
+
+
+
+
+);
+
+export default BlockModal;
diff --git a/src/experiments/mm-p2p/Flyover.jsx b/src/experiments/mm-p2p/Flyover.jsx
new file mode 100644
index 00000000..69314ab2
--- /dev/null
+++ b/src/experiments/mm-p2p/Flyover.jsx
@@ -0,0 +1,83 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import Sidecard from './Sidecard';
+
+const Flyover = ({
+ isStatic,
+ options,
+}) => {
+ const handleHideFlyoverKeyPress = (event) => {
+ if (event.key === 'Enter') {
+ options.flyover.toggle();
+ }
+ };
+ if (!options.access.isAudit || options.state.afterUpgradeDeadline) {
+ return null;
+ }
+ return (
+
+ { !isStatic && (
+
+
Notifications
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+Flyover.propTypes = {
+ isStatic: PropTypes.bool,
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ isAudit: PropTypes.bool.isRequired,
+ }),
+ flyover: PropTypes.shape({
+ toggle: PropTypes.func.isRequired,
+ }),
+ state: PropTypes.shape({
+ afterUpgradeDeadline: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+Flyover.defaultProps = {
+ isStatic: false,
+ options: {
+ access: {
+ isAudit: false,
+ },
+ flyover: {
+ toggle: () => {},
+ },
+ state: {
+ afterUpgradeDeadline: false,
+ },
+ },
+};
+
+export default Flyover;
diff --git a/src/experiments/mm-p2p/Flyover.scss b/src/experiments/mm-p2p/Flyover.scss
new file mode 100644
index 00000000..33b0a29e
--- /dev/null
+++ b/src/experiments/mm-p2p/Flyover.scss
@@ -0,0 +1,38 @@
+@media only screen and (min-width: 600px) {
+ .mmp2p-flyover {
+ min-width: 315px !important;
+ h4 {
+ font-size: 16px;
+ }
+ }
+}
+.mmp2p-flyover {
+ &:not(.static) {
+ height: 100% !important;
+ height: 393px;
+ }
+ &.static {
+ margin-bottom: 20px;
+ }
+ border: solid 1px #e1dddb;
+ width: 330px;
+ vertical-align: top;
+ margin-left: 20px;
+ padding: 0 20px 20px 20px;
+ .mmp2p-notification-div {
+ margin: 0 -20px 0px;
+ padding: 9px 20px 0;
+ font-size: 16px;
+ }
+ .mmp2p-notification-block {
+ height: 9px;
+ background: #F9F9F9;
+ margin: 7px -20px 0;
+ border-top: 1px solid rgb(225, 221, 219);
+ border-bottom: 1px solid rgb(225, 221, 219);
+ }
+ svg.mmp2p-flyover-icon {
+ float: right;
+ margin-top: 5.5px;
+ }
+}
diff --git a/src/experiments/mm-p2p/FlyoverMobile.jsx b/src/experiments/mm-p2p/FlyoverMobile.jsx
new file mode 100644
index 00000000..e3c3f794
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverMobile.jsx
@@ -0,0 +1,86 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
+
+import Sidecard from './Sidecard';
+
+export const FlyoverMobile = ({ options }) => {
+ const {
+ access: { isAudit },
+ flyover: { toggle },
+ state: { afterUpgradeDeadline },
+ } = options;
+
+ const handleReturnSpanKeyPress = (event) => {
+ if (event.key === 'Enter') {
+ toggle();
+ }
+ };
+
+ if (!isAudit || afterUpgradeDeadline) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Back to course
+
+
+
+
+
+ Notifications
+
+
+
+
+
+
+ );
+};
+FlyoverMobile.propTypes = {
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ isAudit: PropTypes.bool.isRequired,
+ }),
+ flyover: PropTypes.shape({
+ toggle: PropTypes.func.isRequired,
+ }),
+ state: PropTypes.shape({
+ afterUpgradeDeadline: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+FlyoverMobile.defaultProps = {
+ options: {
+ access: {
+ isAudit: false,
+ },
+ flyover: {
+ toggle: () => {},
+ },
+ state: {
+ afterUpgradeDeadline: false,
+ },
+ },
+};
+
+export default FlyoverMobile;
diff --git a/src/experiments/mm-p2p/FlyoverMobile.scss b/src/experiments/mm-p2p/FlyoverMobile.scss
new file mode 100644
index 00000000..78d70ce0
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverMobile.scss
@@ -0,0 +1,37 @@
+.mmp2p-flyover-mobile {
+ vertical-align: top;
+ padding: 0 20px 20px 20px;
+ position: fixed;
+ background-color: white;
+ z-index: 1;
+ height: 100%;
+ width: 100%;
+ top: 0;
+ left: 0;
+
+ .mmp2p-mobile-return-div {
+ margin: 0 -20px;
+ padding: 9px 20px 15px;
+ font-size: 16px;
+ border-bottom: 1px solid rgb(225, 221, 219);
+ }
+ .mmp2p-mobile-return-span {
+ color: #00262B;
+ cursor: pointer;
+ }
+ .mmp2p-notification-div {
+ margin: 0 -20px 15px;
+ padding: 9px 20px 0;
+ font-size: 16px;
+ }
+ .mmp2p-notification-span {
+ color: #00262B;
+ }
+ .mmp2p-notification-block {
+ height: 9px;
+ background: #F9F9F9;
+ margin: 7px -20px 0;
+ border-top: 1px solid rgb(225, 221, 219);
+ border-bottom: 1px solid rgb(225, 221, 219);
+ }
+}
diff --git a/src/experiments/mm-p2p/FlyoverTrigger.jsx b/src/experiments/mm-p2p/FlyoverTrigger.jsx
new file mode 100644
index 00000000..dd23e57e
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverTrigger.jsx
@@ -0,0 +1,58 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import FlyoverTriggerIcon from './FlyoverTriggerIcon';
+import { isMobile } from './utils';
+
+export const FlyoverTrigger = ({ options }) => {
+ const { isVisible, toggle } = options.flyover;
+ if (!options.access.isAudit || options.state.afterUpgradeDeadline) {
+ return null;
+ }
+ return (!isMobile() && (
+
+
+
+ ));
+};
+
+FlyoverTrigger.propTypes = {
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ isAudit: PropTypes.bool.isRequired,
+ }),
+ flyover: PropTypes.shape({
+ isVisible: PropTypes.bool.isRequired,
+ toggle: PropTypes.func.isRequired,
+ }),
+ state: PropTypes.shape({
+ afterUpgradeDeadline: PropTypes.bool.isRequired,
+ isEnabled: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+FlyoverTrigger.defaultProps = {
+ options: {
+ access: { isAudit: false },
+ flyover: {
+ isVisible: false,
+ toggle: () => {},
+ },
+ state: {
+ afterUpgradeDeadline: false,
+ isEnabled: false,
+ },
+ },
+};
+
+export default FlyoverTrigger;
diff --git a/src/experiments/mm-p2p/FlyoverTrigger.scss b/src/experiments/mm-p2p/FlyoverTrigger.scss
new file mode 100644
index 00000000..76f06e99
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverTrigger.scss
@@ -0,0 +1,8 @@
+.mmp2p-toggle-flyover-button {
+ margin-left: auto;
+ margin-top: 0px !important;
+ border-bottom: none;
+ &.flyover-visible {
+ border-bottom: 2px solid #00262b;
+ }
+}
diff --git a/src/experiments/mm-p2p/FlyoverTriggerIcon.jsx b/src/experiments/mm-p2p/FlyoverTriggerIcon.jsx
new file mode 100644
index 00000000..66ec5ac7
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverTriggerIcon.jsx
@@ -0,0 +1,48 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+
+const FlyoverTriggerIcon = () => (
+
+
+
+
+
+
+
+);
+
+export default FlyoverTriggerIcon;
diff --git a/src/experiments/mm-p2p/FlyoverTriggerMobile.jsx b/src/experiments/mm-p2p/FlyoverTriggerMobile.jsx
new file mode 100644
index 00000000..327ff54b
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverTriggerMobile.jsx
@@ -0,0 +1,54 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import FlyoverTriggerIcon from './FlyoverTriggerIcon';
+import { isMobile } from './utils';
+
+const FlyoverTriggerMobile = ({ options }) => {
+ const { isVisible, toggle } = options.flyover;
+ if (!options.access.isAudit || options.state.afterUpgradeDeadline) {
+ return null;
+ }
+ return (isMobile() && (
+
+
+
+ ));
+};
+
+FlyoverTriggerMobile.propTypes = {
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ isAudit: PropTypes.bool.isRequired,
+ }),
+ flyover: PropTypes.shape({
+ isVisible: PropTypes.bool.isRequired,
+ toggle: PropTypes.func.isRequired,
+ }),
+ state: PropTypes.shape({
+ afterUpgradeDeadline: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+FlyoverTriggerMobile.defaultProps = {
+ options: {
+ access: { isAudit: false },
+ flyover: {
+ isVisible: true,
+ toggle: () => {},
+ },
+ state: { afterUpgradeDeadline: false },
+ },
+};
+
+export default FlyoverTriggerMobile;
diff --git a/src/experiments/mm-p2p/FlyoverTriggerMobile.scss b/src/experiments/mm-p2p/FlyoverTriggerMobile.scss
new file mode 100644
index 00000000..1d073460
--- /dev/null
+++ b/src/experiments/mm-p2p/FlyoverTriggerMobile.scss
@@ -0,0 +1,7 @@
+.mmp2p-toggle-flyover-button-mobile {
+ border-bottom: none;
+ margin-left: 10px !important;
+ &.flyover-visible {
+ border-bottom: 2px solid #00262b;
+ }
+}
diff --git a/src/experiments/mm-p2p/LockPaywall.jsx b/src/experiments/mm-p2p/LockPaywall.jsx
new file mode 100644
index 00000000..2a7af0fc
--- /dev/null
+++ b/src/experiments/mm-p2p/LockPaywall.jsx
@@ -0,0 +1,45 @@
+import React, { Suspense } from 'react';
+import PropTypes from 'prop-types';
+
+import PageLoading from '../../generic/PageLoading';
+
+const LockPaywallContent = React.lazy(() => import('./LockPaywallContent'));
+
+const LockPaywall = ({ options }) => {
+ if (!(options.meta.gradedLock || options.meta.verifiedLock)) {
+ return null;
+ }
+ return (
+
)}
+ >
+
+
+ );
+};
+LockPaywall.propTypes = {
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ upgradeUrl: PropTypes.string.isRequired,
+ price: PropTypes.string.isRequired,
+ }),
+ meta: PropTypes.shape({
+ gradedLock: PropTypes.bool.isRequired,
+ verifiedLock: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+LockPaywall.defaultProps = {
+ options: {
+ access: {
+ upgradeUrl: '',
+ price: '$23',
+ },
+ meta: {
+ gradedLock: false,
+ verifiedLock: false,
+ },
+ },
+};
+export default LockPaywall;
diff --git a/src/experiments/mm-p2p/LockPaywallContent.jsx b/src/experiments/mm-p2p/LockPaywallContent.jsx
new file mode 100644
index 00000000..b4fb741e
--- /dev/null
+++ b/src/experiments/mm-p2p/LockPaywallContent.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+
+import VerifiedCert from '../../generic/assets/edX_certificate.png';
+
+const LockPaywallContent = ({ options }) => (
+
+
+
+
+
+
+);
+LockPaywallContent.propTypes = {
+ options: PropTypes.shape({
+ access: PropTypes.shape({
+ upgradeUrl: PropTypes.string.isRequired,
+ price: PropTypes.string.isRequired,
+ }),
+ meta: PropTypes.shape({
+ gradedLock: PropTypes.bool.isRequired,
+ verifiedLock: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+LockPaywallContent.defaultProps = {
+ options: {
+ access: {
+ upgradeUrl: '',
+ price: '$23',
+ },
+ meta: {
+ gradedLock: false,
+ verifiedLock: false,
+ },
+ },
+};
+export default LockPaywallContent;
diff --git a/src/experiments/mm-p2p/README.md b/src/experiments/mm-p2p/README.md
new file mode 100644
index 00000000..5bee8b8f
--- /dev/null
+++ b/src/experiments/mm-p2p/README.md
@@ -0,0 +1,12 @@
+# What is this experiment?
+This is an experiment updating the audit experience for specific micromasters courses, limiting
+available access to content to the 1st 2 weeks of content, and restricting the upgrade deadline.
+
+# What is the audience?
+All students in a small number of specific Micro-Masters courses
+
+# Who owns it?
+Sapana Thomas and the Masters-Grades team
+
+# Who is responsible for cleaning up this code?
+The Masters-Grades team is on-tap for cleaning up this code at the end of the experiment
diff --git a/src/experiments/mm-p2p/Sidecard.jsx b/src/experiments/mm-p2p/Sidecard.jsx
new file mode 100644
index 00000000..f991f454
--- /dev/null
+++ b/src/experiments/mm-p2p/Sidecard.jsx
@@ -0,0 +1,42 @@
+import React, { Suspense } from 'react';
+import PropTypes from 'prop-types';
+
+import PageLoading from '../../generic/PageLoading';
+
+const SidecardContent = React.lazy(() => import('./SidecardContent'));
+
+const Sidecard = ({ options }) => (
+
)}
+ >
+
+
+);
+
+Sidecard.propTypes = {
+ options: PropTypes.shape({
+ state: PropTypes.shape({
+ upgradeDeadline: PropTypes.string.isRequired,
+ }),
+ access: PropTypes.shape({
+ accessExpirationDate: PropTypes.string.isRequired,
+ price: PropTypes.string.isRequired,
+ upgradeUrl: PropTypes.string.isRequired,
+ }),
+ }),
+};
+
+Sidecard.defaultProps = {
+ options: {
+ state: {
+ upgradeDeadline: 'Mar 29, 2021 11:59 PM EST',
+ },
+ access: {
+ accessDeadline: 'Mar 21, 2022 11:59 PM EST',
+ price: '$23',
+ upgradeUrl: '',
+ },
+ },
+};
+
+export default Sidecard;
diff --git a/src/experiments/mm-p2p/Sidecard.scss b/src/experiments/mm-p2p/Sidecard.scss
new file mode 100644
index 00000000..a6e46e58
--- /dev/null
+++ b/src/experiments/mm-p2p/Sidecard.scss
@@ -0,0 +1,53 @@
+.mmp2p-sidecard-wrapper {
+ padding-top: 15px;
+ .cert-link {
+ font-weight: 600;
+ color: #00688D;
+ text-decoration: 'underline',
+ }
+ .mmp2p-bullet-list-item {
+ .icon-container {
+ vertical-align: top;
+ display: inline-block;
+ margin-right: 7px;
+ }
+ .bullet-item-content {
+ display: inline-block;
+ width: calc(100% - 26px);
+ }
+ svg {
+ font-size: 14px;
+ margin-right: 5px;
+ }
+ }
+ .mmp2p-sidecard-alert {
+ margin-left: -20px;
+ margin-right: -20px;
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ margin-top: 15px;
+ line-height: 1;
+ background-color: #fffadb;
+ color: black;
+ font-size: 14px;
+ border-radius: 0px !important;
+ &.danger {
+ background-color: #fcf1f4;
+ }
+ }
+
+ .mmp2p-coupon-code {
+ border: solid 1px #e1dddb;
+ padding: 20px;
+ margin: -21px;
+ background: #fbfaf9;
+ font-size: 14px;
+ text-align: center;
+ }
+ .verification-sock {
+ display: none;
+ }
+ .alert-info {
+ display: none;
+ }
+}
diff --git a/src/experiments/mm-p2p/SidecardContent.jsx b/src/experiments/mm-p2p/SidecardContent.jsx
new file mode 100644
index 00000000..95fdd2c2
--- /dev/null
+++ b/src/experiments/mm-p2p/SidecardContent.jsx
@@ -0,0 +1,169 @@
+/* eslint-disable no-use-before-define */
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+const AlertBanner = ({ color, children }) => (
+
+ {children}
+
+);
+AlertBanner.propTypes = {
+ color: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+const localizeTime = (date) => date.toLocaleTimeString('en-US',
+ {
+ hour: '2-digit', minute: 'numeric', hour12: true, timeZoneName: 'short',
+ });
+const localizeDate = (date) => date.toLocaleDateString('en-US',
+ { month: 'long', day: 'numeric' });
+
+const BulletList = ({ children }) => (
+
+);
+BulletList.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+const Sidecard = ({
+ options: {
+ state: { upgradeDeadline },
+ access: { accessExpirationDate, price, upgradeUrl },
+ },
+}) => {
+ const dates = {
+ upgradeDeadline: new Date(upgradeDeadline),
+ accessExpirationDate: new Date(accessExpirationDate),
+ now: new Date(),
+ };
+ const upgradeDeadlineTime = localizeTime(dates.upgradeDeadline);
+ const upgradeDeadlineDate = localizeDate(dates.upgradeDeadline);
+ const daysUntilDeadline = parseInt((dates.upgradeDeadline - dates.now) / (1000 * 60 * 60 * 24), 10);
+ const hoursUntilDeadline = parseInt((dates.upgradeDeadline - dates.now) / (1000 * 60 * 60), 10);
+
+ const accessDeadlineDate = localizeDate(dates.accessExpirationDate);
+
+ const certLink = (
+
+
+ verified certificate
+
+
+ );
+
+ return (
+
+
+ Unlock the full course by {upgradeDeadlineDate} at {upgradeDeadlineTime}
+
+
+
= 7 ? 'yellow' : 'red'}>
+ {(daysUntilDeadline > 1) && `${daysUntilDeadline} days left`}
+ {(daysUntilDeadline === 1) && '1 day left'}
+ {(daysUntilDeadline < 1 && hoursUntilDeadline >= 1) && `${hoursUntilDeadline} hours left`}
+ {(daysUntilDeadline < 1 && hoursUntilDeadline < 1) && 'Less than one hour left'}
+
+
+
+
+ Unlock your access to all course activities, including
+ graded assignments
+
+
+ Earn a {certLink} of completion to showcase on your resume
+
+
+ Support our non-profit mission at edX
+
+
+
+
+ You will lose access to the first two weeks of scheduled content on {accessDeadlineDate}.
+
+
+
+
+ );
+};
+
+Sidecard.propTypes = {
+ options: PropTypes.shape({
+ state: PropTypes.shape({
+ upgradeDeadline: PropTypes.string.isRequired,
+ }),
+ access: PropTypes.shape({
+ accessExpirationDate: PropTypes.string.isRequired,
+ price: PropTypes.string.isRequired,
+ upgradeUrl: PropTypes.string.isRequired,
+ }),
+ }),
+};
+
+const futureDate = (numDays) => {
+ const defaultDate = new Date();
+ defaultDate.setDate(defaultDate.getDate() + numDays);
+ return defaultDate;
+};
+
+Sidecard.defaultProps = {
+ options: {
+ state: {
+ upgradeDeadline: new Date('Mar 29, 2021 11:59 PM EST'),
+ },
+ access: {
+ accessDeadline: futureDate(24),
+ price: '$23',
+ upgradeUrl: '',
+ },
+ },
+};
+
+export default Sidecard;
diff --git a/src/experiments/mm-p2p/index.jsx b/src/experiments/mm-p2p/index.jsx
new file mode 100644
index 00000000..70437cf6
--- /dev/null
+++ b/src/experiments/mm-p2p/index.jsx
@@ -0,0 +1,244 @@
+import { useState } from 'react';
+
+import { useModel } from '../../generic/model-store';
+
+import MMP2PBlockModal from './BlockModal';
+import MMP2PFlyover from './Flyover';
+import MMP2PFlyoverMobile from './FlyoverMobile';
+import MMP2PFlyoverTrigger from './FlyoverTrigger';
+import MMP2PFlyoverTriggerMobile from './FlyoverTriggerMobile';
+import MMP2PLockPaywall from './LockPaywall';
+import MMP2PSidecard from './Sidecard';
+
+import { isMobile, StrictDict } from './utils';
+
+const MMP2PKeys = StrictDict({
+ enableFn: 'enable',
+ flyoverVisible: 'flyoverVisible',
+ state: 'state',
+});
+
+let location;
+const windowKey = (field) => `experiment__mmp2p_${location}_${field}`;
+
+const setWindowVal = (field, val) => {
+ window[windowKey(field)] = val;
+};
+
+const windowVal = (field) => window[windowKey(field)];
+const defaultWindowVal = (field, val) => (
+ windowVal(field) === undefined ? val : windowVal(field)
+);
+
+const externalConfig = {
+ runs: [
+ {
+ upgradeDeadline: 'Mar 29 2021 11:59 PM EST',
+ courses: [
+ {
+ courseRun: 'course-v1:edX+DemoX+Demo_Course',
+ subSections: [
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations',
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations',
+ 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e',
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+const initDatesMMP2P = () => {
+ location = 'dates';
+
+ const defaultState = {
+ isEnabled: false,
+ upgradeDeadline: null,
+ };
+
+ const [MMP2POptions, setMMP2POptions] = useState(
+ defaultWindowVal(MMP2PKeys.state, { ...defaultState }),
+ );
+
+ setWindowVal(MMP2PKeys.enableFn, (upgradeDeadline) => {
+ if (upgradeDeadline === undefined) {
+ setMMP2POptions({ ...defaultState });
+ } else {
+ setMMP2POptions({
+ isEnabled: true,
+ upgradeDeadline,
+ });
+ }
+ });
+
+ return {
+ state: MMP2POptions,
+ };
+};
+
+const initHomeMMP2P = (courseId) => {
+ location = 'home';
+
+ const defaultState = {
+ isEnabled: false,
+ upgradeDeadline: null,
+ afterUpgradeDeadline: false,
+ };
+
+ const [MMP2POptions, setMMP2POptions] = useState(
+ defaultWindowVal(MMP2PKeys.state, { ...defaultState }),
+ );
+
+ setWindowVal(MMP2PKeys.enableFn, (upgradeDeadline) => {
+ if (upgradeDeadline === undefined) {
+ setMMP2POptions({ ...defaultState });
+ } else {
+ setMMP2POptions({
+ isEnabled: true,
+ upgradeDeadline,
+ afterUpgradeDeadline: new Date() > new Date(upgradeDeadline),
+ });
+ }
+ });
+
+ const access = {
+ isAudit: false,
+ accessExpirationDate: null,
+ upgradeUrl: null,
+ price: null,
+ };
+
+ const { accessExpiration, verifiedMode } = useModel('outline', courseId);
+ if (
+ accessExpiration !== null
+ && accessExpiration !== undefined
+ && verifiedMode !== null
+ && verifiedMode !== undefined
+ ) {
+ access.isAudit = true;
+ access.accessExpirationDate = accessExpiration.expirationDate;
+ access.upgradeUrl = accessExpiration.upgradeUrl;
+ access.price = `${verifiedMode.currencySymbol}${verifiedMode.price}`;
+ }
+
+ return {
+ state: MMP2POptions,
+ access,
+ };
+};
+const initCoursewareMMP2P = (courseId, sequenceId, unitId) => {
+ location = 'course';
+
+ const defaultState = {
+ isEnabled: false,
+ upgradeDeadline: null,
+ afterUpgradeDeadline: false,
+ subSections: [],
+ isWhitelisted: false,
+ };
+
+ const [MMP2POptions, _setMMP2POptions] = useState(
+ defaultWindowVal(MMP2PKeys.state, { ...defaultState }),
+ );
+
+ const setMMP2POptions = (options) => {
+ _setMMP2POptions(options);
+ setWindowVal(MMP2PKeys.state, options);
+ };
+
+ const [isMMP2PFlyoverVisible, setMMP2PFlyoverVisible] = useState(
+ isMobile() ? false : defaultWindowVal(MMP2PKeys.flyoverVisible, false),
+ );
+ const flyover = {
+ isVisible: isMMP2PFlyoverVisible,
+ toggle: () => {
+ setMMP2PFlyoverVisible(!isMMP2PFlyoverVisible);
+ setWindowVal(MMP2PKeys.flyoverVisible, !isMMP2PFlyoverVisible);
+ },
+ };
+
+ setWindowVal(MMP2PKeys.enableFn,
+ (upgradeDeadline, subSections) => {
+ if (subSections.length !== undefined && subSections.length > 0) {
+ setMMP2POptions({
+ isEnabled: true,
+ upgradeDeadline,
+ afterUpgradeDeadline: new Date() > new Date(upgradeDeadline),
+ isWhitelisted: subSections.indexOf(sequenceId) > -1,
+ });
+ } else {
+ setMMP2POptions({ ...defaultState });
+ setWindowVal(MMP2PKeys.state, { ...defaultState });
+ }
+ });
+
+ const access = {
+ isAudit: false,
+ accessExpirationDate: null,
+ upgradeUrl: null,
+ price: null,
+ };
+
+ const { accessExpiration, verifiedMode } = useModel('coursewareMeta', courseId);
+ if (
+ accessExpiration !== null
+ && accessExpiration !== undefined
+ && verifiedMode !== null
+ && verifiedMode !== undefined
+ ) {
+ access.isAudit = true;
+ access.accessExpirationDate = accessExpiration.expirationDate;
+ access.upgradeUrl = accessExpiration.upgradeUrl;
+ access.price = `${verifiedMode.currencySymbol}${verifiedMode.price}`;
+ }
+
+ // testing
+ setWindowVal('externalConfig', externalConfig);
+
+ const unitModel = useModel('units', unitId);
+ const graded = unitModel !== undefined ? unitModel.graded : false;
+
+ const meta = {};
+ meta.verifiedLock = (
+ access.isAudit
+ && !MMP2POptions.isWhitelisted
+ );
+ meta.gradedLock = (
+ access.isAudit
+ && MMP2POptions.isWhitelisted
+ && graded
+ );
+ meta.modalLock = (
+ access.isAudit
+ && !MMP2POptions.isWhitelisted
+ && MMP2POptions.afterUpgradeDeadline
+ );
+ meta.showLock = (
+ MMP2POptions.isEnabled
+ && (meta.verifiedLock || meta.gradedLock)
+ );
+ meta.blockContent = (MMP2POptions.isEnabled && meta.verifiedLock);
+
+ return {
+ access,
+ flyover,
+ meta,
+ state: MMP2POptions,
+ };
+};
+
+export {
+ MMP2PBlockModal,
+ MMP2PFlyover,
+ MMP2PFlyoverMobile,
+ MMP2PFlyoverTrigger,
+ MMP2PFlyoverTriggerMobile,
+ MMP2PLockPaywall,
+ MMP2PSidecard,
+ initCoursewareMMP2P,
+ initHomeMMP2P,
+ initDatesMMP2P,
+};
diff --git a/src/experiments/mm-p2p/index.scss b/src/experiments/mm-p2p/index.scss
new file mode 100644
index 00000000..0b278795
--- /dev/null
+++ b/src/experiments/mm-p2p/index.scss
@@ -0,0 +1,6 @@
+@import "./BlockModal.scss";
+@import "./Flyover.scss";
+@import "./FlyoverMobile.scss";
+@import "./FlyoverTrigger.scss";
+@import "./FlyoverTriggerMobile.scss";
+@import "./Sidecard.scss";
diff --git a/src/experiments/mm-p2p/utils.jsx b/src/experiments/mm-p2p/utils.jsx
new file mode 100644
index 00000000..4fc097a2
--- /dev/null
+++ b/src/experiments/mm-p2p/utils.jsx
@@ -0,0 +1,48 @@
+/* eslint-disable no-console */
+import { useContext } from 'react';
+import { AppContext } from '@edx/frontend-platform/react';
+import util from 'util';
+
+export const isMobile = () => {
+ const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
+ return Boolean(
+ userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
+ );
+};
+
+export const getUser = () => useContext(AppContext).authenticatedUser;
+
+const staticReturnOptions = [
+ 'dict',
+ 'inspect',
+ Symbol.toStringTag,
+ util.inspect.custom,
+ Symbol.for('nodejs.util.inspect.custom'),
+];
+
+const strictGet = (target, name) => {
+ if (name === Symbol.toStringTag) {
+ return target;
+ }
+ if (name === 'length') {
+ return target.length;
+ }
+ if (staticReturnOptions.indexOf(name) >= 0) {
+ return target;
+ }
+ if (name === Symbol.iterator) {
+ return { ...target };
+ }
+
+ if (name in target || name === '_reactFragment') {
+ return target[name];
+ }
+
+ console.log(name.toString());
+ console.error({ target, name });
+ const e = Error(`invalid property "${name.toString()}"`);
+ console.error(e.stack);
+ return undefined;
+};
+
+export const StrictDict = (dict) => new Proxy(dict, { get: strictGet });
diff --git a/src/index.scss b/src/index.scss
index 507324c0..b330fffc 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -369,3 +369,6 @@
@import 'course-home/outline-tab/widgets/UpgradeCard.scss';
@import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss';
@import 'courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp';
+
+/** [MM-P2P] Experiment */
+@import 'experiments/mm-p2p/index.scss';