diff --git a/common/static/common/js/components/ExperimentalCarousel.jsx b/common/static/common/js/components/ExperimentalCarousel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..93d1181d6c2219b99cacd576ddab330a92cedcb0 --- /dev/null +++ b/common/static/common/js/components/ExperimentalCarousel.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import Slider from 'react-slick'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +/** Experimental Carousel as part of https://openedx.atlassian.net/browse/LEARNER-3583 **/ + +function NextArrow(props) { + const {currentSlide, slideCount, onClick, displayedSlides} = props; + const showArrow = slideCount - currentSlide > displayedSlides; + const opts = { + className: classNames('js-carousel-nav', 'carousel-arrow', 'next', 'btn btn-secondary', {'active': showArrow}), + onClick + }; + + if (!showArrow) { + opts.disabled = 'disabled'; + } + + return ( + <button {...opts}> + <span>Next </span> + <span className="icon fa fa-chevron-right" aria-hidden="true"></span> + <span className="sr">{ 'Scroll carousel forwards' }</span> + </button> + ); +} + +function PrevArrow(props) { + const {currentSlide, onClick} = props; + const showArrow = currentSlide > 0; + const opts = { + className: classNames('js-carousel-nav', 'carousel-arrow', 'prev', 'btn btn-secondary', {'active': showArrow}), + onClick + }; + + if (!showArrow) { + opts.disabled = 'disabled'; + } + + return ( + <button {...opts} > + <span className="icon fa fa-chevron-left" aria-hidden="true"></span> + <span> Prev</span> + <span className="sr">{ 'Scroll carousel backwards' }</span> + </button> + ); +} + +export default class ExperimentalCarousel extends React.Component { + constructor(props) { + super(props); + + this.state = { + // Default to undefined to not focus on page load + activeIndex: undefined, + }; + + this.carousels = []; + + this.afterChange = this.afterChange.bind(this); + this.getCarouselContent = this.getCarouselContent.bind(this); + } + + afterChange(activeIndex) { + this.setState({ activeIndex }); + } + + componentDidUpdate() { + const { activeIndex } = this.state; + + if (!isNaN(activeIndex)) { + this.carousels[activeIndex].focus(); + } + } + + getCarouselContent() { + return this.props.slides.map((slide, i) => { + const firstIndex = this.state.activeIndex || 0; + const lastIndex = firstIndex + this.props.slides.length; + const tabIndex = (firstIndex <= i && i < lastIndex) ? undefined : '-1'; + const carouselLinkProps = { + ref: (item) => { + this.carousels[i] = item; + }, + tabIndex: tabIndex, + className: 'carousel-item' + } + + return ( + <div {...carouselLinkProps}> + { slide } + </div> + ); + }); + } + + render() { + const carouselSettings = { + accessibility: true, + dots: true, + infinite: false, + speed: 500, + className: 'carousel-wrapper', + nextArrow: <NextArrow displayedSlides={1} />, + prevArrow: <PrevArrow />, + afterChange: this.afterChange, + slidesToShow: 1, + slidesToScroll: 1, + initialSlide: 0, + }; + + return ( + <Slider {...carouselSettings} > + {this.getCarouselContent()} + </Slider> + ); + } +} + +ExperimentalCarousel.propTypes = { + slides: PropTypes.array.isRequired +}; \ No newline at end of file diff --git a/common/static/common/js/components/UpsellExperimentModal.jsx b/common/static/common/js/components/UpsellExperimentModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..57f01a433877e23020e2b7c01416df417449afa6 --- /dev/null +++ b/common/static/common/js/components/UpsellExperimentModal.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, Button } from '@edx/paragon/static'; + +import ExperimentalCarousel from './ExperimentalCarousel.jsx'; + +// https://openedx.atlassian.net/browse/LEARNER-3583 + +export class UpsellExperimentModal extends React.Component { + constructor(props) { + super(props); + + this.state = { + isOpen: true, + } + } + + render() { + const slides = [ + (<div> + <div className="my-stats-introduction">My Stats introduces new personalized views that help you track your progress towards completing your course!</div> + <div className="my-stats-slide-header">With My Stats you will see your:</div> + <ul className="upsell-modal-checkmark-group"> + <li className="upsell-modal-checkmark">Course Activity Streak (log in every week to keep your streak alive)</li> + <li className="upsell-modal-checkmark">Grade Progress (see how you're tracking towards a passing grade)</li> + <li className="upsell-modal-checkmark">Discussion Forum Engagements (top learners use the forums - how do you measure up?)</li> + </ul> + </div>), + (<div> + <div className="slide-header"><b>Course Activity Streak</b></div> + <span className="course-activity-streak-information">Did you know the learners most likely to complete a course log in every week? Let us help you track your weekly streak - log in every week and learn something new! You can also see how many of the other learners in your course logged in this week.</span> + </div>), + (<div> + <div className="slide-header"><b>Grade Progress</b></div> + <span className="grade-progress-information">Wonder how you're doing in the course so far? We can not only show you all your grades, and how much each assignment is worth, but also upcoming graded assignments. This is a great way to track what you might need to work on for a final exam.</span> + </div>), + (<div> + <div className="slide-header"><b>Discussion engagements</b></div> + <span className="discussion-engagements-information">A large percentage of successful learners are engaged on the discussion forums. Compare your forum stats to previous graduates!</span> + </div>), + ]; + const body = ( + <div> + <ExperimentalCarousel id="upsell-modal" slides={slides} /> + <img + className="upsell-certificate" + src="https://courses.edx.org/static/images/edx-verified-mini-cert.png" + alt="" + /> + </div> + ); + const { buttonDestinationURL } = this.props; + return ( + <Modal + open={this.state.isOpen} + className="upsell-modal" + title={"My Stats"} + onClose={() => {}} + body={body} + buttons={[ + (<Button + label={"Upgrade ($100 USD)"} + display={"Upgrade ($100 USD)"} + buttonType="success" + // unfortunately, Button components don't have an href component + onClick={() => window.location = buttonDestinationURL} + />), + ]} + /> + ); + } +} + +UpsellExperimentModal.propTypes = { + buttonDestinationURL: PropTypes.string.isRequired, +}; \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 74ee56fd245dbd570711ee37cc8c0b6172fa405b..f3451bdc50704bd4a73301e06bb0a23c3ee2bc20 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -955,6 +955,7 @@ STATIC_ROOT = ENV_ROOT / "staticfiles" STATICFILES_DIRS = [ COMMON_ROOT / "static", PROJECT_ROOT / "static", + NODE_MODULES_ROOT / "@edx", ] FAVICON_PATH = 'images/favicon.ico' diff --git a/lms/static/sass/_experiments.scss b/lms/static/sass/_experiments.scss index 22127b3f9cb24d72f41dfd12ca158b5cd300c408..bb1c4ed4ee4fc0026e0ef71ec2efbb174dabf042 100644 --- a/lms/static/sass/_experiments.scss +++ b/lms/static/sass/_experiments.scss @@ -39,3 +39,439 @@ max-width: 420px; margin: 0 0 10px 0; } + +/* Part of LEARNER-3583 experiment (https://openedx.atlassian.net/browse/LEARNER-3583) */ + +/* modal-specific */ + +#upsell-modal { + display: none; + + /* slick modal from https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.6.0/slick.min.css */ + + .slick-list, + .slick-slider, + .slick-track { + position: relative; + display: block; + } + + .slick-loading { + .slick-slide, + .slick-track { + visibility: hidden; + } + } + + .slick-slider { + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -khtml-user-select: none; + -ms-touch-action: pan-y; + touch-action: pan-y; + -webkit-tap-highlight-color: transparent; + } + + .slick-list { + overflow: hidden; + margin: 0; + padding: 0; + + &:focus { + outline: 0; + } + + &.dragging { + cursor: pointer; + cursor: hand; + } + } + + .slick-slider { + .slick-list, + .slick-track { + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + -o-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + } + + .slick-track { + top: 0; + left: 0; + + &::after, + &::before { + display: table; + content: ''; + } + + &::after { + clear: both; + } + } + + .slick-slide { + display: none; + float: left; + height: 100%; + min-height: 1px; + } + + [dir=rtl] .slick-slide { + float: right; + } + + .slick-slide { + img { + display: block; + } + + &.slick-loading img { + display: none; + } + + &.dragging img { + pointer-events: none; + } + } + + .slick-initialized .slick-slide { + display: block; + } + + .slick-vertical .slick-slide { + display: block; + height: auto; + border: 1px solid transparent; + } + + .slick-arrow.slick-hidden { + display: none; + } + + /* slick theme from https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.6.0/slick-theme.min.css */ + + .slick-dots, + .slick-next, + .slick-prev { + position: absolute; + display: block; + padding: 0; + } + + .slick-dots li button::before, + .slick-next::before, + .slick-prev::before { + font-family: slick; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + + .slick-next, + .slick-prev { + font-size: 0; + line-height: 0; + top: 50%; + width: 20px; + height: 20px; + -webkit-transform: translate(0, -50%); + -ms-transform: translate(0, -50%); + transform: translate(0, -50%); + cursor: pointer; + color: transparent; + border: none; + outline: 0; + background: 0 0; + } + + .slick-next { + &:focus, + &:hover { + color: transparent; + outline: 0; + background: 0 0; + } + } + + .slick-prev { + &:focus, + &:hover { + color: transparent; + outline: 0; + background: 0 0; + } + } + + .slick-next { + &:focus::before, + &:hover::before { + opacity: 1; + } + } + + .slick-prev { + &:focus::before, + &:hover::before { + opacity: 1; + } + } + + .slick-next.slick-disabled::before, + .slick-prev.slick-disabled::before { + opacity: 0.25; + } + + .slick-next::before { + font-size: 20px; + line-height: 1; + opacity: 0.75; + color: #fff; + } + + .slick-prev { + &::before { + font-size: 20px; + line-height: 1; + opacity: 0.75; + color: #fff; + } + + left: -25px; + } + + [dir=rtl] .slick-prev { + right: -25px; + left: auto; + } + + .slick-prev::before { + content: '• '; + } + + .slick-next::before, + [dir=rtl] .slick-prev::before { + content: '•’'; + } + + .slick-next { + right: -25px; + } + + [dir=rtl] .slick-next { + right: auto; + left: -25px; + + &::before { + content: '•Â'; + } + } + + .slick-dotted.slick-slider { + margin-bottom: 30px; + } + + .slick-dots { + bottom: -25px; + width: 100%; + margin: 0; + list-style: none; + text-align: center; + + li { + position: relative; + display: inline-block; + width: 20px; + height: 20px; + margin: 0 5px; + padding: 0; + cursor: pointer; + + button { + font-size: 0; + line-height: 0; + display: block; + width: 20px; + height: 20px; + padding: 5px; + cursor: pointer; + color: transparent; + border: 0; + outline: 0; + background: 0 0; + + &:focus, + &:hover { + outline: 0; + } + + &:focus::before, + &:hover::before { + opacity: 1; + } + + &::before { + font-size: 6px; + line-height: 20px; + position: absolute; + top: 0; + left: 0; + width: 20px; + height: 20px; + content: '•'; + text-align: center; + opacity: 0.25; + color: #000; + } + } + + &.slick-active button::before { + opacity: 0.75; + color: #000; + } + } + } + + .paragon__btn.paragon__btn-secondary { + display: none; + } + + .paragon__btn.paragon__btn-success { + font-size: initial; + float: left; + background: green; + } + + .paragon__btn.paragon__btn-light { + line-height: 0.25; + font-weight: 600; + background: white; + } + + .paragon__modal-title { + font-weight: 600; + color: black; + } + + .paragon__modal-title::before { + content: "NEW"; + font-size: small; + background-color: #ccdde6; + color: #00507e; + margin-right: 8px; + box-shadow: 0 0 0 4px #ccdde6; + font-weight: 500; + border-radius: 3px; + } + + .paragon__modal-footer { + display: inline-block; + } + + .carousel-arrow { + background: white; + } + + .carousel-arrow.prev { + position: absolute; + bottom: 0; + right: 80%; + box-shadow: initial; + font-size: small; + color: black; + background-color: white; + } + + .carousel-arrow.next { + position: absolute; + bottom: 0; + left: 80%; + box-shadow: initial; + font-size: small; + color: black; + background-color: white; + } + + .carousel-wrapper { + display: flex; + + .slick-dots { + margin-bottom: 6%; + margin-left: 33%; + width: 33%; + display: block; + + li > button::before { + font-size: 15px; + } + } + + .slick-list { + margin-bottom: 10%; + } + } + + .slick-slide.carousel-item { + padding: 0 10px; + min-width: 400px; + } + + .upsell-certificate { + height: 43px; + position: absolute; + left: 40%; + margin-top: 29px; + } + + .upsell-modal-checkmark-group { + list-style: none; + margin-left: 0; + } + + .upsell-modal-checkmark::before { + content: '✔ï¸\00a0\00a0\00a0\00a0'; + } + + .js-carousel-nav { + border: 1px solid #b9bcbc; + } + + .icon.fa { + font-size: 10px; + margin: 5px; + } + + .upsell-certificate { + margin-top: 30px; + margin-left: 20px; + } + + .paragon__btn.paragon__btn-success { + font-weight: 600; + padding: 9px 20px; + } + + .slick-slide.carousel-item { + color: grey; + } + + .slide-header { + padding-bottom: 10px; + color: black; + } + + .my-stats-introduction { + padding-bottom: 10px; + } + + .my-stats-slide-header { + padding-bottom: 10px; + } +} diff --git a/lms/static/sass/bootstrap/lms-main.scss b/lms/static/sass/bootstrap/lms-main.scss index b913349416db4f51229e95108b359e705919b9f6..99b9b7b314ead7915cb438196c94b592f8b88fb6 100644 --- a/lms/static/sass/bootstrap/lms-main.scss +++ b/lms/static/sass/bootstrap/lms-main.scss @@ -33,3 +33,6 @@ $static-path: '../..'; // Extra theme-specific rules @import 'lms/theme/extras'; + +// Experiments +@import 'experiments'; \ No newline at end of file diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index 2da4fd2a4bb5ebb6bcca61890b618cfcaf68b9f9..528259ac3eba15c1645c999028fadf17a300c957 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -15,10 +15,24 @@ from django_comment_client.permissions import has_permission from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REVIEWS_TOOL_FLAG +from openedx.features.learner_analytics import ENABLE_DASHBOARD_TAB %> +<%block name="header_extras"> + <link rel="stylesheet" type="text/css" href="${static.url('paragon/static/paragon.min.css')}" /> +</%block> + <%block name="content"> <div class="course-view page-content-container" id="course-container"> + + % if ENABLE_DASHBOARD_TAB.is_enabled(course_key): + ${static.renderReact( + component="UpsellExperimentModal", + id="upsell-modal", + props={}, + )} + % endif + <header class="page-header has-secondary"> <div class="page-header-main"> <nav aria-label="${_('Course Outline')}" class="sr-is-focusable" tabindex="-1"> diff --git a/package-lock.json b/package-lock.json index 734a0c8eda907164bd4318dadd51e3e6734933f0..29b85a819ed7f019e7073d40183114eac013390d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1562,6 +1562,11 @@ "map-obj": "1.0.1" } }, + "can-use-dom": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/can-use-dom/-/can-use-dom-0.1.0.tgz", + "integrity": "sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo=" + }, "caniuse-api": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-1.6.1.tgz", @@ -2833,6 +2838,11 @@ "tapable": "0.2.8" } }, + "enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha1-PoeAybi4NQhMP2DhZtvDwqPImBQ=" + }, "ent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", @@ -6130,6 +6140,14 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, + "json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha1-tje9O6nqvhIsg+lyBIOusQ0skEo=", + "requires": { + "string-convert": "0.2.1" + } + }, "json3": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", @@ -8894,6 +8912,20 @@ "prop-types": "15.6.0" } }, + "react-slick": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.16.0.tgz", + "integrity": "sha512-QSN0w2f5yiyIwGwlccDGC0veke3NkigHuHycq+qr0GWyrQ7BWIvGPS6J8nOt2LLrwPU816Ib8zgqsH8MTBhWCA==", + "requires": { + "can-use-dom": "0.1.0", + "classnames": "2.2.5", + "create-react-class": "15.6.2", + "enquire.js": "2.1.6", + "json2mq": "0.2.0", + "object-assign": "4.1.1", + "slick-carousel": "1.8.1" + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -9662,6 +9694,11 @@ } } }, + "slick-carousel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz", + "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==" + }, "sntp": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", @@ -10027,6 +10064,11 @@ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" }, + "string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c=" + }, "string-replace-webpack-plugin": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/string-replace-webpack-plugin/-/string-replace-webpack-plugin-0.1.3.tgz", diff --git a/package.json b/package.json index 6dafb7dcb6e9ae86d9309479ec6eba1bf237a94a..f2b30ee51b3bff0203aa4fc983842fccecd01ce3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "backbone.paginator": "2.0.6", "bootstrap": "4.0.0-beta.2", "coffee-loader": "0.7.3", + "classnames": "2.2.5", "coffee-script": "1.6.1", "css-loader": "0.28.8", "edx-pattern-library": "0.18.1", @@ -38,6 +39,7 @@ "raw-loader": "0.5.1", "react": "15.6.2", "react-dom": "15.6.2", + "react-slick": "0.16.0", "requirejs": "2.3.5", "rtlcss": "2.2.1", "sass-loader": "6.0.6", diff --git a/webpack.common.config.js b/webpack.common.config.js index 243adef74ec05be184c3590587a0ebb7bc84b2a6..bb47698b4d982c91b5865b058878c0a3e1efa38a 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -26,6 +26,7 @@ module.exports = { SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx', AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx', LearnerAnalyticsDashboard: './lms/static/js/learner_analytics_dashboard/LearnerAnalyticsDashboard.jsx', + UpsellExperimentModal: './lms/static/common/js/components/UpsellExperimentModal.jsx', // Features CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',