From 7174da78d0ef19632bc526282e3f9b4cc991bcbd Mon Sep 17 00:00:00 2001 From: Nawfal Ahmed <111358247+NawfalAhmed@users.noreply.github.com> Date: Tue, 16 May 2023 15:34:42 +0500 Subject: [PATCH] feat: subscription changes and alerts for program dashboard and details (#32217) * feat: subscription changes on program dashboard and details (#31909) - add a h2 programs heading on program dashboard - subscription ui changes on program dashboard - subscription alerts on both pages * feat: add subscription upsell, fix responsive layout of program dashboard (#31943) * test: update tests for subscription changes on program dashboard (#32021) * feat: subscription api changes on program dashboard (#32085) * feat: add subscription segment events to program details and dashboard (#32164) * feat: subscription changes on program dashboard and details pages (#32205) --- lms/static/images/arrow-upright-icon.svg | 5 +- lms/static/images/launch-icon.svg | 3 + lms/static/images/warning-icon.svg | 5 + .../models/program_subscription_model.js | 11 ++- .../learner_dashboard/program_list_factory.js | 19 +++- .../spec/collection_list_view_spec.js | 10 ++ .../spec/program_alert_list_view_spec.js | 58 +++++++++++ .../spec/program_card_view_spec.js | 25 ++++- .../spec/program_details_sidebar_view_spec.js | 2 +- .../spec/program_details_view_spec.js | 3 +- .../spec/program_list_header_view_spec.js | 92 +++++++++++++++++ .../spec/sidebar_view_spec.js | 45 ++++++++- .../views/program_alert_list_view.js | 89 +++++++++++++++++ .../views/program_card_view.js | 16 ++- .../views/program_details_view.js | 81 +++++++++++++++ .../views/program_list_header_view.js | 98 +++++++++++++++++++ .../learner_dashboard/views/sidebar_view.js | 16 +++ .../views/subscription_upsell_view.js | 30 ++++++ lms/static/sass/views/_program-details.scss | 30 +++++- lms/static/sass/views/_program-list.scss | 94 +++++++++++------- .../explore_new_programs.underscore | 14 ++- .../program_alert_list_view.underscore | 22 +++++ .../learner_dashboard/program_card.underscore | 5 + .../program_details_sidebar.underscore | 2 +- .../program_details_tab_view.underscore | 32 ++++-- .../program_details_view.underscore | 32 ++++-- .../program_list_header_view.underscore | 2 + .../learner_dashboard/programs_fragment.html | 12 ++- .../learner_dashboard/sidebar.underscore | 3 + .../subscription_upsell_view.underscore | 14 +++ 30 files changed, 774 insertions(+), 96 deletions(-) create mode 100644 lms/static/images/launch-icon.svg create mode 100644 lms/static/images/warning-icon.svg create mode 100644 lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js create mode 100644 lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js create mode 100644 lms/static/js/learner_dashboard/views/program_alert_list_view.js create mode 100644 lms/static/js/learner_dashboard/views/program_list_header_view.js create mode 100644 lms/static/js/learner_dashboard/views/subscription_upsell_view.js create mode 100644 lms/templates/learner_dashboard/program_alert_list_view.underscore create mode 100644 lms/templates/learner_dashboard/program_list_header_view.underscore create mode 100644 lms/templates/learner_dashboard/subscription_upsell_view.underscore diff --git a/lms/static/images/arrow-upright-icon.svg b/lms/static/images/arrow-upright-icon.svg index 3838d3f035..e4417a13bb 100644 --- a/lms/static/images/arrow-upright-icon.svg +++ b/lms/static/images/arrow-upright-icon.svg @@ -1,4 +1,3 @@ - - + + diff --git a/lms/static/images/launch-icon.svg b/lms/static/images/launch-icon.svg new file mode 100644 index 0000000000..8751e57cf0 --- /dev/null +++ b/lms/static/images/launch-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lms/static/images/warning-icon.svg b/lms/static/images/warning-icon.svg new file mode 100644 index 0000000000..412368e945 --- /dev/null +++ b/lms/static/images/warning-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lms/static/js/learner_dashboard/models/program_subscription_model.js b/lms/static/js/learner_dashboard/models/program_subscription_model.js index 164f5a3881..0a52d9358c 100644 --- a/lms/static/js/learner_dashboard/models/program_subscription_model.js +++ b/lms/static/js/learner_dashboard/models/program_subscription_model.js @@ -11,8 +11,8 @@ class ProgramSubscriptionModel extends Backbone.Model { const { subscriptionData: [data = {}], programData: { subscription_prices }, - urls, - userPreferences, + urls = {}, + userPreferences = {}, } = context; const priceInUSD = subscription_prices?.find(({ currency }) => currency === 'USD')?.price; @@ -35,6 +35,8 @@ class ProgramSubscriptionModel extends Backbone.Model { ? trialMoment.isAfter(moment.utc()) : false; + const remainingDays = trialMoment.diff(moment.utc(), 'days'); + const [nextPaymentDate] = ProgramSubscriptionModel.formatDate( data.next_payment_date, userPreferences @@ -50,6 +52,7 @@ class ProgramSubscriptionModel extends Backbone.Model { { hasActiveTrial, nextPaymentDate, + remainingDays, subscriptionPrice, subscriptionState, subscriptionUrl, @@ -66,8 +69,8 @@ class ProgramSubscriptionModel extends Backbone.Model { return ['', '']; } - const userTimezone = userPreferences?.time_zone || 'UTC'; - const userLanguage = userPreferences?.['pref-lang'] || 'en'; + const userTimezone = userPreferences.time_zone || 'UTC'; + const userLanguage = userPreferences['pref-lang'] || 'en'; const context = { datetime: date, timezone: userTimezone, diff --git a/lms/static/js/learner_dashboard/program_list_factory.js b/lms/static/js/learner_dashboard/program_list_factory.js index d7449e22c6..c4621b3568 100644 --- a/lms/static/js/learner_dashboard/program_list_factory.js +++ b/lms/static/js/learner_dashboard/program_list_factory.js @@ -1,26 +1,37 @@ +import Backbone from 'backbone'; + import CollectionListView from './views/collection_list_view'; import ProgramCardView from './views/program_card_view'; import ProgramCollection from './collections/program_collection'; import ProgressCollection from './collections/program_progress_collection'; import SidebarView from './views/sidebar_view'; +import HeaderView from './views/program_list_header_view'; function ProgramListFactory(options) { const progressCollection = new ProgressCollection(); + const subscriptionCollection = new Backbone.Collection(); if (options.userProgress) { progressCollection.set(options.userProgress); options.progressCollection = progressCollection; // eslint-disable-line no-param-reassign } + if (options.programsSubscriptionData.length) { + subscriptionCollection.set(options.programsSubscriptionData); + options.subscriptionCollection = subscriptionCollection; // eslint-disable-line no-param-reassign + } + + if (options.programsData.length) { + new HeaderView({ + context: options, + }).render(); + } + new CollectionListView({ el: '.program-cards-container', childView: ProgramCardView, collection: new ProgramCollection(options.programsData), context: options, - titleContext: { - el: 'h2', - title: 'Your Programs', - }, }).render(); if (options.programsData.length) { diff --git a/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js b/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js index 8fffb0306f..628848f75b 100644 --- a/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js @@ -1,5 +1,7 @@ /* globals setFixtures */ +import Backbone from 'backbone'; + import CollectionListView from '../views/collection_list_view'; import ProgramCardView from '../views/program_card_view'; import ProgramCollection from '../collections/program_collection'; @@ -9,6 +11,7 @@ describe('Collection List View', () => { let view = null; let programCollection; let progressCollection; + let subscriptionCollection; const context = { programsData: [ { @@ -98,14 +101,21 @@ describe('Collection List View', () => { not_started: 3, }, ], + programsSubscriptionData: [{ + resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8', + subscription_state: 'active', + }], + isUserB2CSubscriptionsEnabled: false, }; beforeEach(() => { setFixtures('
'); programCollection = new ProgramCollection(context.programsData); progressCollection = new ProgressCollection(); + subscriptionCollection = new Backbone.Collection(context.programsSubscriptionData); progressCollection.set(context.userProgress); context.progressCollection = progressCollection; + context.subscriptionCollection = subscriptionCollection; view = new CollectionListView({ el: '.program-cards-container', diff --git a/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js b/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js new file mode 100644 index 0000000000..ed12a98ab5 --- /dev/null +++ b/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js @@ -0,0 +1,58 @@ +/* globals setFixtures */ + +import ProgramAlertListView from '../views/program_alert_list_view'; + +describe('Program Alert List View', () => { + let view = null; + const context = { + enrollmentAlerts: [{ title: 'Test Program' }], + trialEndingAlerts: [{ + title: 'Test Program', + hasActiveTrial: true, + nextPaymentDate: 'May 8, 2023', + remainingDays: 2, + subscriptionPrice: '$100', + subscriptionState: 'active', + subscriptionUrl: null, + trialEndDate: 'Apr 20, 2023', + trialEndTime: '5:59 am', + trialLength: 7, + }], + pageType: 'programDetails', + }; + + beforeEach(() => { + setFixtures('
'); + view = new ProgramAlertListView({ + el: '.js-program-details-alerts', + context, + }); + view.render(); + }); + + afterEach(() => { + view.remove(); + }); + + it('should exist', () => { + expect(view).toBeDefined(); + }); + + it('should render no enrollement alert', () => { + expect(view.$('.alert:first .alert-heading').text().trim()).toEqual( + 'Enroll in a Test Program course' + ); + expect(view.$('.alert:first .alert-message').text().trim()).toEqual( + 'You have an active subscription to the Test Program program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.' + ); + }); + + it('should render subscription trial is expiring alert', () => { + expect(view.$('.alert:last .alert-heading').text().trim()).toEqual( + 'Subscription trial expires in 2 days' + ); + expect(view.$('.alert:last .alert-message').text().trim()).toEqual( + 'Your Test Program trial will expire in 2 days at 5:59 am on Apr 20, 2023 and the card on file will be charged $100/month.' + ); + }); +}); diff --git a/lms/static/js/learner_dashboard/spec/program_card_view_spec.js b/lms/static/js/learner_dashboard/spec/program_card_view_spec.js index 6ad3c72895..1cbd91fb62 100644 --- a/lms/static/js/learner_dashboard/spec/program_card_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_card_view_spec.js @@ -57,6 +57,10 @@ describe('Program card View', () => { not_started: 3, }, ]; + const subscriptionCollection = new Backbone.Collection([{ + resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8', + subscription_state: 'active', + }]); const progressCollection = new ProgressCollection(); const cardRenders = ($card) => { expect($card).toBeDefined(); @@ -74,6 +78,8 @@ describe('Program card View', () => { model: programModel, context: { progressCollection, + subscriptionCollection, + isUserB2CSubscriptionsEnabled: true, }, }); }); @@ -123,7 +129,10 @@ describe('Program card View', () => { view.remove(); view = new ProgramCardView({ model: programModel, - context: {}, + context: { + subscriptionCollection, + isUserB2CSubscriptionsEnabled: true, + }, }); cardRenders(view.$el); expect(view.$('.progress').length).toEqual(0); @@ -136,7 +145,10 @@ describe('Program card View', () => { programModel = new ProgramModel(programNoBanner); view = new ProgramCardView({ model: programModel, - context: {}, + context: { + subscriptionCollection, + isUserB2CSubscriptionsEnabled: true, + }, }); cardRenders(view.$el); expect(view.$el.find('.banner-image').attr('srcset')).toEqual(''); @@ -151,9 +163,16 @@ describe('Program card View', () => { programModel = new ProgramModel(programNoBanner); view = new ProgramCardView({ model: programModel, - context: {}, + context: { + subscriptionCollection, + isUserB2CSubscriptionsEnabled: true, + }, }); cardRenders(view.$el); expect(view.$el.find('.banner-image').attr('srcset')).toEqual(''); }); + + it('should render the subscription badge if subscription is active', () => { + expect(view.$('.subscription-badge .badge').html().trim()).toEqual('Subscribed'); + }); }); diff --git a/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js b/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js index ea902fc8d0..2c05d6bc7e 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js @@ -25,7 +25,7 @@ describe('Program Progress View', () => { "url": "/certificates/bed3980e67ca40f0b31e309d9dfe9e7e", "type": "course", "title": "Introduction to the Treatment of Urban Sewage" } ], - urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/", "program_record_url": "/foo/bar", "buy_subscription_url": "/subscriptions", "manage_subscription_url": "/orders", "subscriptions_learner_help_center_url": "/learner"}, + urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/", "program_record_url": "/foo/bar", "buy_subscription_url": "/subscriptions", "orders_and_subscriptions_url": "/orders", "subscriptions_learner_help_center_url": "/learner"}, userPreferences: {"pref-lang": "en"} }; /* eslint-enable */ diff --git a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js index 60ac335830..b3f40de14c 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js @@ -485,6 +485,7 @@ describe('Program Details View', () => { buy_subscription_url: '/subscriptions', manage_subscription_url: '/orders', subscriptions_learner_help_center_url: '/learner', + orders_and_subscriptions_url: '/orders', }, userPreferences: { 'pref-lang': 'en', @@ -693,7 +694,7 @@ describe('Program Details View', () => { it('should render the get subscription link if program is subscription eligible', () => { testSubscriptionState( 'pre', - 'Start 7-Day free trial', + 'Start 7-day free trial', '$100/month subscription after trial ends. Cancel anytime.' ); }); diff --git a/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js b/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js new file mode 100644 index 0000000000..b021a1d220 --- /dev/null +++ b/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js @@ -0,0 +1,92 @@ +/* globals setFixtures */ + +import Backbone from 'backbone'; + +import ProgressCollection from '../collections/program_progress_collection'; +import ProgramListHeaderView from '../views/program_list_header_view'; + +describe('Program List Header View', () => { + let view = null; + const context = { + programsData: [ + { + uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c', + title: 'edX Demonstration Program', + subscription_eligible: null, + subscription_prices: [], + detail_url: '/dashboard/programs/5b234e3c-3a2e-472e-90db-6f51501dc86c/', + }, + { + uuid: 'b90d70d5-f981-4508-bdeb-5b792d930c03', + title: 'Test Program', + subscription_eligible: true, + subscription_prices: [{ price: '500.00', currency: 'USD' }], + detail_url: '/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/', + }, + ], + programsSubscriptionData: [ + { + id: 'eeb25640-9741-4c11-963c-8a27337f217c', + resource_id: 'b90d70d5-f981-4508-bdeb-5b792d930c03', + trial_end: '2022-04-20T05:59:42Z', + next_payment_date: '2023-05-08T05:59:42Z', + subscription_state: 'active', + }, + ], + userProgress: [ + { + uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c', + completed: 0, + in_progress: 1, + not_started: 0, + }, + { + uuid: 'b90d70d5-f981-4508-bdeb-5b792d930c03', + completed: 0, + in_progress: 0, + not_started: 3, + }, + ], + isUserB2CSubscriptionsEnabled: true, + }; + + beforeEach(() => { + context.subscriptionCollection = new Backbone.Collection( + context.programsSubscriptionData + ); + context.progressCollection = new ProgressCollection( + context.userProgress + ); + setFixtures('
'); + view = new ProgramListHeaderView({ + context, + }); + view.render(); + }); + + afterEach(() => { + view.remove(); + }); + + it('should exist', () => { + expect(view).toBeDefined(); + }); + + it('should render the program heading', () => { + expect(view.$('h2:first').text().trim()).toEqual('My programs'); + }); + + it('should render a program alert', () => { + expect( + view.$('.js-program-list-alerts .alert .alert-heading').html().trim() + ).toEqual('Enroll in a Test Program course'); + expect( + view.$('.js-program-list-alerts .alert .alert-message') + ).toContainHtml( + 'According to our records, you are not enrolled in any courses included in your Test Program program subscription. Enroll in a course from the Program Details page.' + ); + expect( + view.$('.js-program-list-alerts .alert .view-button').attr('href') + ).toEqual('/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/'); + }); +}); diff --git a/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js b/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js index 5e29bc8400..81606b6e39 100644 --- a/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js @@ -6,6 +6,7 @@ describe('Sidebar View', () => { let view = null; const context = { marketingUrl: 'https://www.example.org/programs', + isUserB2CSubscriptionsEnabled: true, }; beforeEach(() => { @@ -26,18 +27,52 @@ describe('Sidebar View', () => { expect(view).toBeDefined(); }); + it('should not render the subscription upsell section if B2CSubscriptions are disabled', () => { + view.remove(); + view = new SidebarView({ + el: '.sidebar', + context: { + ...context, + isUserB2CSubscriptionsEnabled: false, + } + }); + view.render(); + expect(view.$('.js-subscription-upsell')[0]).not.toBeInDOM(); + }); + + it('should render the subscription upsell section', () => { + expect(view.$('.js-subscription-upsell')[0]).toBeInDOM(); + expect(view.$('.js-subscription-upsell .badge').html().trim()) + .toEqual('New'); + expect(view.$('.js-subscription-upsell h4').html().trim()) + .toEqual('Monthly program subscriptions now available'); + expect(view.$('.js-subscription-upsell .advertise-message')) + .toContainText( + 'An easier way to access popular programs with more control over how much you spend.' + ); + expect(view.$('.js-subscription-upsell a span:last').html().trim()) + .toEqual('Explore subscription options'); + expect(view.$('.js-subscription-upsell a').attr('href')) + .not + .toEqual(context.marketingUrl); + }); + it('should load the exploration panel given a marketing URL', () => { - const $sidebar = view.$el; - expect($sidebar.find('.program-advertise .advertise-message').html().trim()) - .toEqual('Browse recently launched courses and see what\'s new in your favorite subjects'); - expect($sidebar.find('.program-advertise .ad-link a').attr('href')).toEqual(context.marketingUrl); + expect(view.$('.program-advertise .advertise-message').html().trim()) + .toEqual( + 'Browse recently launched courses and see what\'s new in your favorite subjects' + ); + expect(view.$('.program-advertise a').attr('href')) + .toEqual(context.marketingUrl); }); it('should not load the advertising panel if no marketing URL is provided', () => { view.remove(); view = new SidebarView({ el: '.sidebar', - context: {}, + context: { + isUserB2CSubscriptionsEnabled: true, + }, }); view.render(); const $ad = view.$el.find('.program-advertise'); diff --git a/lms/static/js/learner_dashboard/views/program_alert_list_view.js b/lms/static/js/learner_dashboard/views/program_alert_list_view.js new file mode 100644 index 0000000000..78ed3ae494 --- /dev/null +++ b/lms/static/js/learner_dashboard/views/program_alert_list_view.js @@ -0,0 +1,89 @@ +import Backbone from 'backbone'; + +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; +import StringUtils from 'edx-ui-toolkit/js/utils/string-utils'; + +import warningIcon from '../../../images/warning-icon.svg'; +import programAlertTpl from '../../../templates/learner_dashboard/program_alert_list_view.underscore'; + +class ProgramAlertListView extends Backbone.View { + constructor(options) { + const defaults = { + el: '.js-program-details-alerts', + }; + super(Object.assign({}, defaults, options)); + } + + initialize({ context }) { + this.tpl = HtmlUtils.template(programAlertTpl); + this.enrollmentAlerts = context.enrollmentAlerts || []; + this.trialEndingAlerts = context.trialEndingAlerts || []; + this.pageType = context.pageType; + this.render(); + } + + render() { + const data = { + alertList: this.getAlertList(), + warningIcon, + }; + HtmlUtils.setHtml(this.$el, this.tpl(data)); + } + + getAlertList() { + const alertList = this.enrollmentAlerts.map( + ({ title: programName, url }) => ({ + url, + urlText: gettext('View program'), + title: StringUtils.interpolate( + gettext('Enroll in a {programName} course'), + { programName } + ), + message: this.pageType === 'programDetails' + ? StringUtils.interpolate( + gettext('You have an active subscription to the {programName} program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.'), + { programName } + ) + : HtmlUtils.interpolateHtml( + gettext('According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.'), + { + programName, + i_start: HtmlUtils.HTML(''), + i_end: HtmlUtils.HTML(''), + } + ), + }) + ); + return alertList.concat(this.trialEndingAlerts.map( + ({ title: programName, remainingDays, ...data }) => { + const title = 'Subscription trial expires in {remainingDays} day'; + const message = 'Your {programName} trial will expire in {remainingDays} day at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}/month.'; + + return { + title: StringUtils.interpolate( + ngettext( + title, + title.replace(/\bday\b/, 'days'), + remainingDays + ), + { remainingDays } + ), + message: StringUtils.interpolate( + ngettext( + message, + message.replace(/\bday\b/, 'days'), + remainingDays + ), + { + programName, + remainingDays, + ...data, + } + ), + }; + } + )); + } +} + +export default ProgramAlertListView; diff --git a/lms/static/js/learner_dashboard/views/program_card_view.js b/lms/static/js/learner_dashboard/views/program_card_view.js index 6ced8727d1..658081ad01 100644 --- a/lms/static/js/learner_dashboard/views/program_card_view.js +++ b/lms/static/js/learner_dashboard/views/program_card_view.js @@ -21,14 +21,21 @@ class ProgramCardView extends Backbone.View { super(Object.assign({}, defaults, options)); } - initialize(data) { + initialize({ context }) { this.tpl = HtmlUtils.template(programCardTpl); - this.progressCollection = data.context.progressCollection; + this.progressCollection = context.progressCollection; if (this.progressCollection) { this.progressModel = this.progressCollection.findWhere({ uuid: this.model.get('uuid'), }); } + this.isSubscribed = ( + context.isUserB2CSubscriptionsEnabled && + context.subscriptionCollection?.some({ + resource_id: this.model.get('uuid'), + subscription_state: 'active', + }) + ) ?? false; this.render(); } @@ -37,7 +44,10 @@ class ProgramCardView extends Backbone.View { const data = $.extend( this.model.toJSON(), this.getProgramProgress(), - { orgList: orgList.join(' ') }, + { + orgList: orgList.join(' '), + isSubscribed: this.isSubscribed, + }, ); HtmlUtils.setHtml(this.$el, this.tpl(data)); diff --git a/lms/static/js/learner_dashboard/views/program_details_view.js b/lms/static/js/learner_dashboard/views/program_details_view.js index d2f98aee29..b672b9ebaf 100644 --- a/lms/static/js/learner_dashboard/views/program_details_view.js +++ b/lms/static/js/learner_dashboard/views/program_details_view.js @@ -9,9 +9,11 @@ import CourseCardCollection from '../collections/course_card_collection'; import CourseCardView from './course_card_view'; import HeaderView from './program_header_view'; import SidebarView from './program_details_sidebar_view'; +import AlertListView from './program_alert_list_view'; import SubscriptionModel from '../models/program_subscription_model'; +import launchIcon from '../../../images/launch-icon.svg'; import restartIcon from '../../../images/restart-icon.svg'; import pageTpl from '../../../templates/learner_dashboard/program_details_view.underscore'; import tabPageTpl from '../../../templates/learner_dashboard/program_details_tab_view.underscore'; @@ -23,6 +25,7 @@ class ProgramDetailsView extends Backbone.View { el: '.js-program-details-wrapper', events: { 'click .complete-program': 'trackPurchase', + 'click .js-subscription-cta': 'trackSubscriptionCTA', }, }; super(Object.assign({}, defaults, options)); @@ -59,6 +62,10 @@ class ProgramDetailsView extends Backbone.View { this.courseData.get('not_started') || [], this.options.userPreferences, ); + this.subscriptionEventParams = { + label: this.options.programData.title, + program_uuid: this.options.programData.uuid, + }; this.render(); @@ -68,6 +75,7 @@ class ProgramDetailsView extends Backbone.View { pageName: 'program_dashboard', linkCategory: 'green_upgrade', }); + this.trackSubscriptionEligibleProgramView(); } static getUrl(base, programData) { @@ -99,6 +107,7 @@ class ProgramDetailsView extends Backbone.View { discussionFragment: this.options.discussionFragment, live_fragment: this.options.live_fragment, isSubscriptionEligible: this.options.isSubscriptionEligible, + launchIcon, restartIcon, }; data = $.extend( @@ -115,6 +124,20 @@ class ProgramDetailsView extends Backbone.View { model: new Backbone.Model(this.options), }); + if (this.options.isSubscriptionEligible) { + const { enrollmentAlerts, trialEndingAlerts } = this.getAlerts(); + + if (enrollmentAlerts.length || trialEndingAlerts.length) { + this.alertListView = new AlertListView({ + context: { + enrollmentAlerts, + trialEndingAlerts, + pageType: 'programDetails', + }, + }); + } + } + if (this.remainingCourseCollection.length > 0) { new CollectionListView({ el: '.js-course-list-remaining', @@ -167,6 +190,33 @@ class ProgramDetailsView extends Backbone.View { }).bind(this); } + getAlerts() { + const alerts = { + enrollmentAlerts: [], + trialEndingAlerts: [], + }; + if (this.subscriptionModel.get('subscriptionState') === 'active') { + if ( + this.courseData.get('in_progress').length === 0 && + this.courseData.get('not_started').length >= 1 + ) { + alerts.enrollmentAlerts.push({ + title: this.programModel.get('title'), + }); + } + if ( + this.subscriptionModel.get('remainingDays') <= 7 && + this.subscriptionModel.get('hasActiveTrial') + ) { + alerts.trialEndingAlerts.push({ + title: this.programModel.get('title'), + ...this.subscriptionModel.toJSON(), + }); + } + } + return alerts; + } + trackPurchase() { const data = this.options.programData; window.analytics.track('edx.bi.user.dashboard.program.purchase', { @@ -175,6 +225,37 @@ class ProgramDetailsView extends Backbone.View { uuid: data.uuid, }); } + + trackSubscriptionCTA() { + const state = this.subscriptionModel.get('subscriptionState'); + + if (state === 'active') { + window.analytics.track( + 'edx.bi.user.subscription.program-detail-page.manage.clicked', + this.subscriptionEventParams + ); + } else { + const isNewSubscription = state !== 'inactive'; + window.analytics.track( + 'edx.bi.user.subscription.program-detail-page.subscribe.clicked', + { + category: `${this.options.programData.variant} bundle`, + is_new_subscription: isNewSubscription, + is_trial_eligible: isNewSubscription, + ...this.subscriptionEventParams, + } + ); + } + } + + trackSubscriptionEligibleProgramView() { + if (this.options.isSubscriptionEligible) { + window.analytics.track( + 'edx.bi.user.subscription.program-detail-page.viewed', + this.subscriptionEventParams + ); + } + } } export default ProgramDetailsView; diff --git a/lms/static/js/learner_dashboard/views/program_list_header_view.js b/lms/static/js/learner_dashboard/views/program_list_header_view.js new file mode 100644 index 0000000000..1019d682bf --- /dev/null +++ b/lms/static/js/learner_dashboard/views/program_list_header_view.js @@ -0,0 +1,98 @@ +import Backbone from 'backbone'; + +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; + +import AlertListView from './program_alert_list_view'; + +import SubscriptionModel from '../models/program_subscription_model'; + +import programListHeaderTpl from '../../../templates/learner_dashboard/program_list_header_view.underscore'; + +class ProgramListHeaderView extends Backbone.View { + constructor(options) { + const defaults = { + el: '.js-program-list-header', + }; + super(Object.assign({}, defaults, options)); + } + + initialize({ context }) { + this.context = context; + this.tpl = HtmlUtils.template(programListHeaderTpl); + this.programAndSubscriptionData = context.programsData + .map((programData) => ({ + programData, + subscriptionData: context.subscriptionCollection + ?.findWhere({ + resource_id: programData.uuid, + subscription_state: 'active', + }) + ?.toJSON(), + })) + .filter(({ subscriptionData }) => !!subscriptionData); + this.render(); + } + + render() { + HtmlUtils.setHtml(this.$el, this.tpl(this.context)); + this.postRender(); + } + + postRender() { + if (this.context.isUserB2CSubscriptionsEnabled) { + const enrollmentAlerts = this.getEnrollmentAlerts(); + const trialEndingAlerts = this.getTrialEndingAlerts(); + + if (enrollmentAlerts.length || trialEndingAlerts.length) { + this.alertListView = new AlertListView({ + el: '.js-program-list-alerts', + context: { + enrollmentAlerts, + trialEndingAlerts, + pageType: 'programList', + }, + }); + } + } + } + + getEnrollmentAlerts() { + return this.programAndSubscriptionData + .map(({ programData, subscriptionData }) => { + const progress = this.context.progressCollection?.findWhere({ + uuid: programData.uuid, + in_progress: 0, + }); + return ( + progress?.get('not_started') >= 1 && { + title: programData.title, + url: programData.detail_url, + } + ); + }) + .filter(Boolean); + } + + getTrialEndingAlerts() { + return this.programAndSubscriptionData + .map(({ programData, subscriptionData }) => { + const subscriptionModel = new SubscriptionModel({ + context: { + programData, + subscriptionData: [subscriptionData], + userPreferences: this.context?.userPreferences, + }, + }); + return ( + subscriptionModel.get('remainingDays') <= 7 && + subscriptionModel.get('hasActiveTrial') && { + title: programData.title, + ...subscriptionModel.toJSON(), + } + ); + }) + .filter(Boolean); + } +} + +export default ProgramListHeaderView; diff --git a/lms/static/js/learner_dashboard/views/sidebar_view.js b/lms/static/js/learner_dashboard/views/sidebar_view.js index fcc7ea7fea..8f4a75c166 100644 --- a/lms/static/js/learner_dashboard/views/sidebar_view.js +++ b/lms/static/js/learner_dashboard/views/sidebar_view.js @@ -3,6 +3,7 @@ import Backbone from 'backbone'; import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; import NewProgramsView from './explore_new_programs_view'; +import SubscriptionUpsellView from './subscription_upsell_view'; import sidebarTpl from '../../../templates/learner_dashboard/sidebar.underscore'; @@ -10,6 +11,9 @@ class SidebarView extends Backbone.View { constructor(options) { const defaults = { el: '.sidebar', + events: { + 'click .js-subscription-upsell-cta ': 'trackSubscriptionUpsellCTA', + }, }; super(Object.assign({}, defaults, options)); } @@ -25,10 +29,22 @@ class SidebarView extends Backbone.View { } postRender() { + if (this.context.isUserB2CSubscriptionsEnabled) { + this.subscriptionUpsellView = new SubscriptionUpsellView({ + context: this.context, + }); + } + this.newProgramsView = new NewProgramsView({ context: this.context, }); } + + trackSubscriptionUpsellCTA() { + window.analytics.track( + 'edx.bi.user.subscription.program-dashboard.upsell.clicked' + ); + } } export default SidebarView; diff --git a/lms/static/js/learner_dashboard/views/subscription_upsell_view.js b/lms/static/js/learner_dashboard/views/subscription_upsell_view.js new file mode 100644 index 0000000000..d629792aa0 --- /dev/null +++ b/lms/static/js/learner_dashboard/views/subscription_upsell_view.js @@ -0,0 +1,30 @@ +import Backbone from 'backbone'; + +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; + +import subscriptionUpsellTpl from '../../../templates/learner_dashboard/subscription_upsell_view.underscore'; + +class SubscriptionUpsellView extends Backbone.View { + constructor(options) { + const defaults = { + el: '.js-subscription-upsell', + }; + super(Object.assign({}, defaults, options)); + } + + initialize(options) { + this.tpl = HtmlUtils.template(subscriptionUpsellTpl); + this.data = options.context; + this.render(); + } + + render() { + const data = $.extend(this.context, { + minSubscriptionPrice: '$39', + trialLength: 7, + }); + HtmlUtils.setHtml(this.$el, this.tpl(data)); + } +} + +export default SubscriptionUpsellView; diff --git a/lms/static/sass/views/_program-details.scss b/lms/static/sass/views/_program-details.scss index 6394caf6d2..9056f04a13 100644 --- a/lms/static/sass/views/_program-details.scss +++ b/lms/static/sass/views/_program-details.scss @@ -90,6 +90,21 @@ $btn-color-primary: $primary-dark; } } +.program-details-alerts { + .page-banner { + margin: 0; + padding: 0 0 48px; + gap: 24px; + } +} + +.program-details-tab-alerts { + .page-banner { + margin: 0; + gap: 24px; + } +} + // CSS for April 2017 version of Program Details Page .program-details { .window-wrap { @@ -435,13 +450,20 @@ $btn-color-primary: $primary-dark; } .upgrade-subscription { - margin: 10px 15px 10px 5px; - gap: 15px; + margin: 16px 0 10px; + row-gap: 16px; + column-gap: 24px; + } + + .subscription-icon-launch { + width: 22.5px; + height: 22.5px; + margin-inline-start: 8px; } .subscription-icon-restart { - width: 24px; - height: 24px; + width: 22.5px; + height: 22.5px; margin-inline-end: 8px; } diff --git a/lms/static/sass/views/_program-list.scss b/lms/static/sass/views/_program-list.scss index 3cc8a5b908..c00ae3a444 100644 --- a/lms/static/sass/views/_program-list.scss +++ b/lms/static/sass/views/_program-list.scss @@ -1,68 +1,86 @@ +.program-list-alerts { + .page-banner { + padding-top: 32px; + gap: 24px; + } +} + .program-list-wrapper { @include make-row(); - - max-width: 73.125rem; + padding: ($baseline*2) ($baseline/2); + max-width: 82rem; margin: 0 auto; - @include media-breakpoint-up(sm) { - padding: $baseline; + @include media-breakpoint-up(md) { + padding: ($baseline*2) 0; + } + + @include media-breakpoint-up(lg) { + padding: ($baseline*2) $baseline; + } + + .program-list-container { + @include media-breakpoint-up(lg) { + padding-right: $baseline; + } + } + + .view-button { + background: theme-color("success"); + border-color: theme-color("success"); + border-radius: 0; + color: $white; + padding: 7px; + text-align: center; + font-size: 0.875rem; } } .program-cards-container { @include grid-container(); - @include make-col(12); + padding-top: 32px; - padding: 0 8px; - - @include media-breakpoint-up(sm) { - @include make-col(9); + .subscription-badge { + position: absolute; + top: 8px; + left: 8px; + z-index: 10; } } .sidebar { - @include make-col(12); @include float(right); + display: flex; + flex-direction: column; + align-items: stretch; + gap: 24px; - margin-bottom: $baseline; - padding: 0 8px; - - @include media-breakpoint-up(sm) { - @include make-col(3); + @include media-breakpoint-up(md) { + padding-top: 76px; } .aside { + display: flex; + flex-direction: column; padding: $baseline; - margin-bottom: $baseline; box-sizing: border-box; - border: 1px solid $gray-500; + gap: 16px; + background: #fbfaf9; + border: 1px solid #d7d3d1; } .program-advertise { clear: both; + } - .new-programs-btn { - width: 100%; - text-align: center; - padding: 10px; - white-space: normal; - border-color: theme-color("primary"); - color: theme-color("primary"); - font-weight: 600; + .advertise-message { + font-size: 1rem; + color: $gray-dark; + margin: 0; + } - &:hover, - &:active, - &:focus { - background: theme-color("primary"); - color: $white; - } - } - - .advertise-message { - font-size: 0.75rem; - color: $gray-dark; - margin-bottom: $baseline; - } + .view-button { + white-space: normal; } } diff --git a/lms/templates/learner_dashboard/explore_new_programs.underscore b/lms/templates/learner_dashboard/explore_new_programs.underscore index b1a2385ba1..584f80c411 100644 --- a/lms/templates/learner_dashboard/explore_new_programs.underscore +++ b/lms/templates/learner_dashboard/explore_new_programs.underscore @@ -1,9 +1,7 @@ - - +

+ + + <%- gettext('Explore new programs') %> + diff --git a/lms/templates/learner_dashboard/program_alert_list_view.underscore b/lms/templates/learner_dashboard/program_alert_list_view.underscore new file mode 100644 index 0000000000..eeb9e2d0d1 --- /dev/null +++ b/lms/templates/learner_dashboard/program_alert_list_view.underscore @@ -0,0 +1,22 @@ +
+ <% _.each(alertList, function({ title, message, url, urlText }){ %> + + <% }); %> +
diff --git a/lms/templates/learner_dashboard/program_card.underscore b/lms/templates/learner_dashboard/program_card.underscore index de98c952dd..c9364d6ca2 100644 --- a/lms/templates/learner_dashboard/program_card.underscore +++ b/lms/templates/learner_dashboard/program_card.underscore @@ -61,3 +61,8 @@ +<% if (isSubscribed) { %> +
+ <%- gettext('Subscribed') %> +
+<% } %> diff --git a/lms/templates/learner_dashboard/program_details_sidebar.underscore b/lms/templates/learner_dashboard/program_details_sidebar.underscore index 3146127dde..067833fdae 100644 --- a/lms/templates/learner_dashboard/program_details_sidebar.underscore +++ b/lms/templates/learner_dashboard/program_details_sidebar.underscore @@ -32,7 +32,7 @@ ), { subscriptionPrice, - a_start: HtmlUtils.HTML(``), + a_start: HtmlUtils.HTML(``), a_end: HtmlUtils.HTML(''), } ) %> diff --git a/lms/templates/learner_dashboard/program_details_tab_view.underscore b/lms/templates/learner_dashboard/program_details_tab_view.underscore index cb6f0fd6a2..37b5597e86 100644 --- a/lms/templates/learner_dashboard/program_details_tab_view.underscore +++ b/lms/templates/learner_dashboard/program_details_tab_view.underscore @@ -1,4 +1,5 @@
+
<% if (programTabViewEnabled) { %> @@ -48,9 +49,24 @@ <% } %> <% if (isSubscriptionEligible) { %> -
- - <% if (subscriptionState === 'inactive') { %> +
+ + target="_blank" + rel="noopener noreferrer" + <% } %> + > + <% if (subscriptionState === 'active') { %> +
+ <%- gettext('Manage my subscription') %> +
+ <% // xss-lint: disable=underscore-not-escaped %> + <%= launchIcon %> +
+
+ <% } else if (subscriptionState === 'inactive') { %>
<% // xss-lint: disable=underscore-not-escaped %> @@ -59,12 +75,10 @@ <%- gettext('Restart my subscription') %>
<% } else { %> - <%- StringUtils.interpolate(gettext( - subscriptionState === 'active' - ? 'Manage my subscription' - : 'Start {trialLength}-Day free trial' - ), - { trialLength } ) %> + <%- StringUtils.interpolate( + gettext('Start {trialLength}-day free trial'), + { trialLength } + ) %> <% } %>
diff --git a/lms/templates/learner_dashboard/program_details_view.underscore b/lms/templates/learner_dashboard/program_details_view.underscore index f7ed71f867..8e3b9ec8e9 100644 --- a/lms/templates/learner_dashboard/program_details_view.underscore +++ b/lms/templates/learner_dashboard/program_details_view.underscore @@ -1,4 +1,5 @@
+
@@ -23,9 +24,24 @@
<% } %> <% if (isSubscriptionEligible) { %> -
- - <% if (subscriptionState === 'inactive') { %> +
+ + target="_blank" + rel="noopener noreferrer" + <% } %> + > + <% if (subscriptionState === 'active') { %> +
+ <%- gettext('Manage my subscription') %> +
+ <% // xss-lint: disable=underscore-not-escaped %> + <%= launchIcon %> +
+
+ <% } else if (subscriptionState === 'inactive') { %>
<% // xss-lint: disable=underscore-not-escaped %> @@ -34,12 +50,10 @@ <%- gettext('Restart my subscription') %>
<% } else { %> - <%- StringUtils.interpolate(gettext( - subscriptionState === 'active' - ? 'Manage my subscription' - : 'Start {trialLength}-Day free trial' - ), - { trialLength } ) %> + <%- StringUtils.interpolate( + gettext('Start {trialLength}-day free trial'), + { trialLength } + ) %> <% } %>
diff --git a/lms/templates/learner_dashboard/program_list_header_view.underscore b/lms/templates/learner_dashboard/program_list_header_view.underscore new file mode 100644 index 0000000000..b3a19f55d7 --- /dev/null +++ b/lms/templates/learner_dashboard/program_list_header_view.underscore @@ -0,0 +1,2 @@ +

<%- gettext('My programs') %>

+
diff --git a/lms/templates/learner_dashboard/programs_fragment.html b/lms/templates/learner_dashboard/programs_fragment.html index d24ec8e6a5..329217bf4e 100644 --- a/lms/templates/learner_dashboard/programs_fragment.html +++ b/lms/templates/learner_dashboard/programs_fragment.html @@ -10,8 +10,11 @@ from openedx.core.djangolib.js_utils import ( %>
-
- +
+
+
+
+
<%block name="js_extra"> @@ -19,7 +22,10 @@ from openedx.core.djangolib.js_utils import ( ProgramListFactory({ marketingUrl: '${marketing_url | n, js_escaped_string}', programsData: ${programs | n, dump_js_escaped_json}, - userProgress: ${progress | n, dump_js_escaped_json} + programsSubscriptionData: ${programs_subscription_data | n, dump_js_escaped_json}, + userProgress: ${progress | n, dump_js_escaped_json}, + userPreferences: ${user_preferences | n, dump_js_escaped_json}, + isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json} }); diff --git a/lms/templates/learner_dashboard/sidebar.underscore b/lms/templates/learner_dashboard/sidebar.underscore index 7f02d9862e..8f8db1b97a 100644 --- a/lms/templates/learner_dashboard/sidebar.underscore +++ b/lms/templates/learner_dashboard/sidebar.underscore @@ -1 +1,4 @@ +<% if (isUserB2CSubscriptionsEnabled) { %> + +<% } %>
diff --git a/lms/templates/learner_dashboard/subscription_upsell_view.underscore b/lms/templates/learner_dashboard/subscription_upsell_view.underscore new file mode 100644 index 0000000000..da9c5fd4b0 --- /dev/null +++ b/lms/templates/learner_dashboard/subscription_upsell_view.underscore @@ -0,0 +1,14 @@ +<%- gettext('New') %> +

<%- gettext('Monthly program subscriptions now available') %>

+ + + + <%- gettext('Explore subscription options') %> +