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)
This commit is contained in:
Nawfal Ahmed
2023-05-16 15:34:42 +05:00
committed by GitHub
parent 960781ea3f
commit 7174da78d0
30 changed files with 774 additions and 96 deletions

View File

@@ -1,4 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 0V2H11.59L0 13.59L1.41 15L13 3.41V10H15V0H5Z" fill="#454545
"/>
<svg width="12" height="12" viewBox="0 -1 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 0.21875V1.71875H8.6925L0 10.4113L1.0575 11.4688L9.75 2.77625V7.71875H11.25V0.21875H3.75Z" fill="#454545"/>
</svg>

Before

Width:  |  Height:  |  Size: 179 B

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 19H5V5H12V3H3V21H21V12H19V19ZM14 3V5H17.59L7.76 14.83L9.17 16.24L19 6.41V10H21V3H14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L1 21H23L12 2Z" fill="#F0CC00"/>
<path d="M13 16H11V18H13V16Z" fill="#111111"/>
<path d="M13 10H11V14H13V10Z" fill="#111111"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

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

View File

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

View File

@@ -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('<div class="program-cards-container"></div>');
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',

View File

@@ -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('<div class="js-program-details-alerts"></div>');
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.'
);
});
});

View File

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

View File

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

View File

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

View File

@@ -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('<div class="js-program-list-header"></div>');
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 <i>Program Details</i> page.'
);
expect(
view.$('.js-program-list-alerts .alert .view-button').attr('href')
).toEqual('/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/');
});
});

View File

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

View File

@@ -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>'),
i_end: HtmlUtils.HTML('</i>'),
}
),
})
);
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
<div class="advertise-message">
<p class="advertise-message">
<%- gettext('Browse recently launched courses and see what\'s new in your favorite subjects') %>
</div>
<div class="ad-link">
<a href="<%- marketingUrl %>" class="btn new-programs-btn">
<span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore New Programs') %></span>
</a>
</div>
</p>
<a href="<%- marketingUrl %>" class="btn-brand btn cta-primary view-button align-self-stretch">
<span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore new programs') %></span>
</a>

View File

@@ -0,0 +1,22 @@
<div class="page-banner d-flex flex-column">
<% _.each(alertList, function({ title, message, url, urlText }){ %>
<div class="alert alert-warning alert-button-container flex-column flex-sm-row align-items-sm-center justify-content-sm-between m-0" role="alert">
<div class="alert-container d-flex" >
<div class="alert-warning-icon alert-message-height">
<% // xss-lint: disable=underscore-not-escaped %>
<%= warningIcon %>
</div>
<div class="alert-container d-flex flex-column align-items-start">
<h4 class="alert-heading m-0"><%- title %></h4>
<% // xss-lint: disable=underscore-not-escaped %>
<p class="alert-message alert-message-height m-0"><%= message %></p>
</div>
</div>
<% if (url && urlText) { %>
<a href="<%- url %>" class="btn-brand btn cta-primary view-button">
<%- urlText %>
</a>
<% } %>
</div>
<% }); %>
</div>

View File

@@ -61,3 +61,8 @@
</picture>
</div>
</a>
<% if (isSubscribed) { %>
<div class="subscription-badge">
<span class="badge badge-light"><%- gettext('Subscribed') %></span>
</div>
<% } %>

View File

@@ -32,7 +32,7 @@
),
{
subscriptionPrice,
a_start: HtmlUtils.HTML(`<a class="subscription-link" href="${manage_subscription_url}">`),
a_start: HtmlUtils.HTML(`<a class="subscription-link" href="${orders_and_subscriptions_url}">`),
a_end: HtmlUtils.HTML('</a>'),
}
) %>

View File

@@ -1,4 +1,5 @@
<header class="js-program-header program-header full-width-banner"></header>
<div class="js-program-details-alerts program-details-tab-alerts program-subscription-alert-wrapper col-12 col-md-8"></div>
<!-- TODO: consider if article is the most appropriate element here -->
<% if (programTabViewEnabled) { %>
@@ -48,9 +49,24 @@
</div>
<% } %>
<% if (isSubscriptionEligible) { %>
<div class="d-flex flex-column align-items-start flex-sm-row align-items-sm-center upgrade-subscription">
<a href="<%- subscriptionUrl %>" class="btn-brand btn cta-primary upgrade-button">
<% if (subscriptionState === 'inactive') { %>
<div class="d-flex flex-column align-items-start flex-xl-row align-items-xl-center upgrade-subscription">
<a
href="<%- subscriptionUrl %>"
class="js-subscription-cta btn-brand btn cta-primary upgrade-button"
<% if (subscriptionState === 'active') { %>
target="_blank"
rel="noopener noreferrer"
<% } %>
>
<% if (subscriptionState === 'active') { %>
<div class="d-flex align-items-center">
<span><%- gettext('Manage my subscription') %></span>
<div class="subscription-icon-launch">
<% // xss-lint: disable=underscore-not-escaped %>
<%= launchIcon %>
</div>
</div>
<% } else if (subscriptionState === 'inactive') { %>
<div class="d-flex align-items-center">
<div class="subscription-icon-restart">
<% // xss-lint: disable=underscore-not-escaped %>
@@ -59,12 +75,10 @@
<span><%- gettext('Restart my subscription') %></span>
</div>
<% } else { %>
<%- StringUtils.interpolate(gettext(
subscriptionState === 'active'
? 'Manage my subscription'
: 'Start {trialLength}-Day free trial'
),
{ trialLength } ) %>
<%- StringUtils.interpolate(
gettext('Start {trialLength}-day free trial'),
{ trialLength }
) %>
<% } %>
</a>
<span class="subscription-info-brief">

View File

@@ -1,4 +1,5 @@
<header class="js-program-header program-header full-width-banner"></header>
<div class="js-program-details-alerts program-details-alerts program-subscription-alert-wrapper col-12 col-md-8"></div>
<!-- TODO: consider if article is the most appropriate element here -->
<div class="col-12 flex-column flex-md-row d-md-flex">
@@ -23,9 +24,24 @@
</div>
<% } %>
<% if (isSubscriptionEligible) { %>
<div class="d-flex flex-column align-items-start flex-sm-row align-items-sm-center upgrade-subscription">
<a href="<%- subscriptionUrl %>" class="btn-brand btn cta-primary upgrade-button">
<% if (subscriptionState === 'inactive') { %>
<div class="d-flex flex-column align-items-start flex-xl-row align-items-xl-center upgrade-subscription">
<a
href="<%- subscriptionUrl %>"
class="js-subscription-cta btn-brand btn cta-primary upgrade-button"
<% if (subscriptionState === 'active') { %>
target="_blank"
rel="noopener noreferrer"
<% } %>
>
<% if (subscriptionState === 'active') { %>
<div class="d-flex align-items-center">
<span><%- gettext('Manage my subscription') %></span>
<div class="subscription-icon-launch">
<% // xss-lint: disable=underscore-not-escaped %>
<%= launchIcon %>
</div>
</div>
<% } else if (subscriptionState === 'inactive') { %>
<div class="d-flex align-items-center">
<div class="subscription-icon-restart">
<% // xss-lint: disable=underscore-not-escaped %>
@@ -34,12 +50,10 @@
<span><%- gettext('Restart my subscription') %></span>
</div>
<% } else { %>
<%- StringUtils.interpolate(gettext(
subscriptionState === 'active'
? 'Manage my subscription'
: 'Start {trialLength}-Day free trial'
),
{ trialLength } ) %>
<%- StringUtils.interpolate(
gettext('Start {trialLength}-day free trial'),
{ trialLength }
) %>
<% } %>
</a>
<span class="subscription-info-brief">

View File

@@ -0,0 +1,2 @@
<h2><%- gettext('My programs') %></h2>
<div class="js-program-list-alerts program-list-alerts program-subscription-alert-wrapper mr-md-3"></div>

View File

@@ -10,8 +10,11 @@ from openedx.core.djangolib.js_utils import (
%>
<div class="program-list-wrapper grid-container">
<div class="program-cards-container col"></div>
<div class="sidebar col col-last"></div>
<div class="program-list-container col-12 col-md-9">
<div class="js-program-list-header"></div>
<div class="program-cards-container col"></div>
</div>
<div class="sidebar col-12 col-md-3"></div>
</div>
<%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}
});
</%static:webpack>
</%block>

View File

@@ -1 +1,4 @@
<% if (isUserB2CSubscriptionsEnabled) { %>
<aside class="aside js-subscription-upsell"></aside>
<% } %>
<div class="aside program-advertise"></div>

View File

@@ -0,0 +1,14 @@
<span class="badge badge-warning align-self-start"><%- gettext('New') %></span>
<h4 class="m-0"><%- gettext('Monthly program subscriptions now available') %></h4>
<p class="advertise-message">
<%- StringUtils.interpolate(
gettext(
'An easier way to access popular programs with more control over how much you spend. Starting at {minSubscriptionPrice} per month after a {trialLength}-day free trial. Cancel anytime.'
),
{ minSubscriptionPrice, trialLength }
) %>
</p>
<a href="" class="js-subscription-upsell-cta btn-brand btn cta-primary view-button align-self-stretch">
<span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore subscription options') %></span>
</a>