Compare commits
4 Commits
dependabot
...
zhancock/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
397f6a77fd | ||
|
|
30c6b23766 | ||
|
|
58cbed25d7 | ||
|
|
73de57ddf4 |
@@ -17,7 +17,6 @@ import { fetchOutlineTab } from '../data';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import Section from './Section';
|
import Section from './Section';
|
||||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
|
||||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||||
import useCourseEndAlert from './alerts/course-end-alert';
|
import useCourseEndAlert from './alerts/course-end-alert';
|
||||||
@@ -39,11 +38,9 @@ const OutlineTab = ({ intl }) => {
|
|||||||
isSelfPaced,
|
isSelfPaced,
|
||||||
org,
|
org,
|
||||||
title,
|
title,
|
||||||
userTimezone,
|
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
|
||||||
courseBlocks: {
|
courseBlocks: {
|
||||||
courses,
|
courses,
|
||||||
sections,
|
sections,
|
||||||
@@ -52,20 +49,12 @@ const OutlineTab = ({ intl }) => {
|
|||||||
selectedGoal,
|
selectedGoal,
|
||||||
weeklyLearningGoalEnabled,
|
weeklyLearningGoalEnabled,
|
||||||
} = {},
|
} = {},
|
||||||
datesBannerInfo,
|
|
||||||
datesWidget: {
|
datesWidget: {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
},
|
},
|
||||||
enableProctoredExams,
|
enableProctoredExams,
|
||||||
offer,
|
|
||||||
timeOffsetMillis,
|
|
||||||
verifiedMode,
|
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
|
||||||
const {
|
|
||||||
marketingUrl,
|
|
||||||
} = useModel('coursewareMeta', courseId);
|
|
||||||
|
|
||||||
const [expandAll, setExpandAll] = useState(false);
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -198,21 +187,7 @@ const OutlineTab = ({ intl }) => {
|
|||||||
<PluginSlot
|
<PluginSlot
|
||||||
id="outline_tab_notifications_slot"
|
id="outline_tab_notifications_slot"
|
||||||
pluginProps={{ courseId }}
|
pluginProps={{ courseId }}
|
||||||
>
|
/>
|
||||||
<UpgradeNotification
|
|
||||||
offer={offer}
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
accessExpiration={accessExpiration}
|
|
||||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
|
||||||
marketingUrl={marketingUrl}
|
|
||||||
upsellPageName="course_home"
|
|
||||||
userTimezone={userTimezone}
|
|
||||||
shouldDisplayBorder
|
|
||||||
timeOffsetMillis={timeOffsetMillis}
|
|
||||||
courseId={courseId}
|
|
||||||
org={org}
|
|
||||||
/>
|
|
||||||
</PluginSlot>
|
|
||||||
<CourseDates />
|
<CourseDates />
|
||||||
<CourseHandouts />
|
<CourseHandouts />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import React from 'react';
|
|||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
@@ -1176,80 +1176,6 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Upgrade Card', () => {
|
|
||||||
it('renders title when upgrade is available', async () => {
|
|
||||||
await fetchAndRender();
|
|
||||||
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays link to upgrade', async () => {
|
|
||||||
await fetchAndRender();
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('viewing upgrade card sends analytics', async () => {
|
|
||||||
sendTrackEvent.mockClear();
|
|
||||||
sendTrackingLogEvent.mockClear();
|
|
||||||
await fetchAndRender();
|
|
||||||
|
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
creative: 'sidebarupsell',
|
|
||||||
name: 'In-Course Verification Prompt',
|
|
||||||
position: 'sidebar-message',
|
|
||||||
promotion_id: 'courseware_verified_certificate_upsell',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking upgrade link sends analytics', async () => {
|
|
||||||
await fetchAndRender();
|
|
||||||
|
|
||||||
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
|
|
||||||
sendTrackEvent.mockClear();
|
|
||||||
sendTrackingLogEvent.mockClear();
|
|
||||||
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
|
|
||||||
|
|
||||||
fireEvent.click(upgradeButton);
|
|
||||||
|
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
|
||||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
creative: 'sidebarupsell',
|
|
||||||
name: 'In-Course Verification Prompt',
|
|
||||||
position: 'sidebar-message',
|
|
||||||
promotion_id: 'courseware_verified_certificate_upsell',
|
|
||||||
});
|
|
||||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
linkCategory: 'green_upgrade',
|
|
||||||
linkName: 'course_home_green',
|
|
||||||
linkType: 'button',
|
|
||||||
pageName: 'course_home',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
|
|
||||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
});
|
|
||||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: courseId,
|
|
||||||
location: 'sidebar-message',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Account Activation Alert', () => {
|
describe('Account Activation Alert', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const intersectionObserverMock = () => ({
|
const intersectionObserverMock = () => ({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, { useContext, useEffect, useMemo } from 'react';
|
|||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||||
import { useModel } from '../../../../../../generic/model-store';
|
import { useModel } from '../../../../../../generic/model-store';
|
||||||
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
|
|
||||||
import { WIDGETS } from '../../../../../../constants';
|
import { WIDGETS } from '../../../../../../constants';
|
||||||
import SidebarContext from '../../../SidebarContext';
|
import SidebarContext from '../../../SidebarContext';
|
||||||
|
|
||||||
@@ -21,17 +20,11 @@ const NotificationsWidget = () => {
|
|||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('coursewareMeta', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
end,
|
end,
|
||||||
enrollmentEnd,
|
enrollmentEnd,
|
||||||
enrollmentMode,
|
enrollmentMode,
|
||||||
enrollmentStart,
|
enrollmentStart,
|
||||||
marketingUrl,
|
|
||||||
offer,
|
|
||||||
start,
|
start,
|
||||||
timeOffsetMillis,
|
|
||||||
userTimezone,
|
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
} = course;
|
} = course;
|
||||||
|
|
||||||
@@ -81,24 +74,7 @@ const NotificationsWidget = () => {
|
|||||||
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
||||||
toggleSidebar: onToggleSidebar,
|
toggleSidebar: onToggleSidebar,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<UpgradeNotification
|
|
||||||
offer={offer}
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
accessExpiration={accessExpiration}
|
|
||||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
|
||||||
marketingUrl={marketingUrl}
|
|
||||||
upsellPageName="in_course"
|
|
||||||
userTimezone={userTimezone}
|
|
||||||
shouldDisplayBorder={false}
|
|
||||||
timeOffsetMillis={timeOffsetMillis}
|
|
||||||
courseId={courseId}
|
|
||||||
org={org}
|
|
||||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
|
||||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
|
||||||
toggleSidebar={onToggleSidebar}
|
|
||||||
/>
|
|
||||||
</PluginSlot>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,9 +18,22 @@ import SidebarContext from '../../../SidebarContext';
|
|||||||
import NotificationsWidget from './NotificationsWidget';
|
import NotificationsWidget from './NotificationsWidget';
|
||||||
import setupDiscussionSidebar from '../../../../test-utils';
|
import setupDiscussionSidebar from '../../../../test-utils';
|
||||||
|
|
||||||
initializeMockApp();
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
jest.mock('@edx/frontend-platform/analytics');
|
||||||
|
|
||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||||
|
...jest.requireActual('@openedx/frontend-plugin-framework'),
|
||||||
|
Plugin: () => 'Plugin',
|
||||||
|
PluginSlot: ({ id, pluginProps }) => (
|
||||||
|
<div data-testid={id}>
|
||||||
|
<button type="button" onClick={pluginProps?.toggleSidebar}>Close</button>
|
||||||
|
PluginSlot_{id}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
initializeMockApp();
|
||||||
|
|
||||||
describe('NotificationsWidget', () => {
|
describe('NotificationsWidget', () => {
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
@@ -93,27 +106,6 @@ describe('NotificationsWidget', () => {
|
|||||||
expect(screen.getByTestId('notification_widget_slot')).toBeInTheDocument();
|
expect(screen.getByTestId('notification_widget_slot')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders upgrade card', async () => {
|
|
||||||
await fetchAndRender(
|
|
||||||
<SidebarContext.Provider value={{
|
|
||||||
currentSidebar: ID,
|
|
||||||
courseId,
|
|
||||||
hideNotificationbar: false,
|
|
||||||
isNotificationbarAvailable: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NotificationsWidget />
|
|
||||||
</SidebarContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The Upgrade Notification should be inside the PluginSlot.
|
|
||||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
|
||||||
expect(UpgradeNotification).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders no notifications bar if no verified mode', async () => {
|
it('renders no notifications bar if no verified mode', async () => {
|
||||||
setMetadata({ verified_mode: null });
|
setMetadata({ verified_mode: null });
|
||||||
await fetchAndRender(
|
await fetchAndRender(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|||||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { useModel } from '@src/generic/model-store';
|
import { useModel } from '@src/generic/model-store';
|
||||||
import UpgradeNotification from '../../../../../generic/upgrade-notification/UpgradeNotification';
|
|
||||||
|
|
||||||
import messages from '../../../messages';
|
import messages from '../../../messages';
|
||||||
import SidebarBase from '../../common/SidebarBase';
|
import SidebarBase from '../../common/SidebarBase';
|
||||||
@@ -23,17 +22,11 @@ const NotificationTray = ({ intl }) => {
|
|||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('coursewareMeta', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
end,
|
end,
|
||||||
enrollmentEnd,
|
enrollmentEnd,
|
||||||
enrollmentMode,
|
enrollmentMode,
|
||||||
enrollmentStart,
|
enrollmentStart,
|
||||||
marketingUrl,
|
|
||||||
offer,
|
|
||||||
start,
|
start,
|
||||||
timeOffsetMillis,
|
|
||||||
userTimezone,
|
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
} = course;
|
} = course;
|
||||||
|
|
||||||
@@ -88,23 +81,7 @@ const NotificationTray = ({ intl }) => {
|
|||||||
notificationCurrentState: upgradeNotificationCurrentState,
|
notificationCurrentState: upgradeNotificationCurrentState,
|
||||||
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<UpgradeNotification
|
|
||||||
offer={offer}
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
accessExpiration={accessExpiration}
|
|
||||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
|
||||||
marketingUrl={marketingUrl}
|
|
||||||
upsellPageName="in_course"
|
|
||||||
userTimezone={userTimezone}
|
|
||||||
shouldDisplayBorder={false}
|
|
||||||
timeOffsetMillis={timeOffsetMillis}
|
|
||||||
courseId={courseId}
|
|
||||||
org={org}
|
|
||||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
|
||||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
|
||||||
/>
|
|
||||||
</PluginSlot>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="p-3 small">{intl.formatMessage(messages.noNotificationsMessage)}</p>
|
<p className="p-3 small">{intl.formatMessage(messages.noNotificationsMessage)}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,26 +94,6 @@ describe('NotificationTray', () => {
|
|||||||
expect(screen.getByTestId('notification_tray_slot')).toBeInTheDocument();
|
expect(screen.getByTestId('notification_tray_slot')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders upgrade card', async () => {
|
|
||||||
await fetchAndRender(
|
|
||||||
<SidebarContext.Provider value={{
|
|
||||||
currentSidebar: ID,
|
|
||||||
courseId,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NotificationTray />
|
|
||||||
</SidebarContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(document.querySelector('.upgrade-notification')).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You have no new notifications at this time.'))
|
|
||||||
.not
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders no notifications message if no verified mode', async () => {
|
it('renders no notifications message if no verified mode', async () => {
|
||||||
setMetadata({ verified_mode: null });
|
setMetadata({ verified_mode: null });
|
||||||
await fetchAndRender(
|
await fetchAndRender(
|
||||||
|
|||||||
@@ -1,564 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import {
|
|
||||||
useIntl, FormattedDate, FormattedMessage, injectIntl,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
import { Button, Icon, IconButton } from '@openedx/paragon';
|
|
||||||
import { Close } from '@openedx/paragon/icons';
|
|
||||||
import { setLocalStorage } from '../../data/localStorage';
|
|
||||||
import { UpgradeButton } from '../upgrade-button';
|
|
||||||
import {
|
|
||||||
VerifiedCertBullet,
|
|
||||||
UnlockGradedBullet,
|
|
||||||
FullAccessBullet,
|
|
||||||
SupportMissionBullet,
|
|
||||||
} from '../upsell-bullets/UpsellBullets';
|
|
||||||
import messages from '../messages';
|
|
||||||
|
|
||||||
const UpsellNoFBECardContent = () => (
|
|
||||||
<ul className="fa-ul upgrade-notification-ul pt-0">
|
|
||||||
<VerifiedCertBullet />
|
|
||||||
<SupportMissionBullet />
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
const UpsellFBEFarAwayCardContent = () => (
|
|
||||||
<ul className="fa-ul upgrade-notification-ul">
|
|
||||||
<VerifiedCertBullet />
|
|
||||||
<UnlockGradedBullet />
|
|
||||||
<FullAccessBullet />
|
|
||||||
<SupportMissionBullet />
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
const UpsellFBESoonCardContent = ({ accessExpirationDate, timezoneFormatArgs }) => {
|
|
||||||
const includingAnyProgress = (
|
|
||||||
<span className="font-weight-bold">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationAccessLoss.progress"
|
|
||||||
defaultMessage="including any progress"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const date = (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessDate"
|
|
||||||
day="numeric"
|
|
||||||
month="long"
|
|
||||||
value={new Date(accessExpirationDate)}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const benefitsOfUpgrading = (
|
|
||||||
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href="https://support.edx.org/hc/en-us/articles/360013426573-What-are-the-differences-between-audit-free-and-verified-paid-courses-">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationVerifiedCert.benefits"
|
|
||||||
defaultMessage="benefits of upgrading"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="upgrade-notification-text">
|
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationAccessLoss"
|
|
||||||
defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}."
|
|
||||||
values={{
|
|
||||||
includingAnyProgress,
|
|
||||||
date,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationVerifiedCert"
|
|
||||||
defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}."
|
|
||||||
values={{ benefitsOfUpgrading }}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
UpsellFBESoonCardContent.propTypes = {
|
|
||||||
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
|
|
||||||
timezoneFormatArgs: PropTypes.shape({
|
|
||||||
timeZone: PropTypes.string,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
UpsellFBESoonCardContent.defaultProps = {
|
|
||||||
timezoneFormatArgs: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PastExpirationCardContent = () => (
|
|
||||||
<div className="upgrade-notification-text">
|
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.pastExpiration.content"
|
|
||||||
defaultMessage="The upgrade deadline for this course passed. To upgrade, enroll in the next available session."
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExpirationCountdown = ({
|
|
||||||
courseId, hoursToExpiration, setupgradeNotificationCurrentState, type,
|
|
||||||
}) => {
|
|
||||||
let expirationText;
|
|
||||||
if (hoursToExpiration >= 24) { // More than 1 day left
|
|
||||||
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
|
|
||||||
if (setupgradeNotificationCurrentState) {
|
|
||||||
if (type === 'access') {
|
|
||||||
setupgradeNotificationCurrentState('accessDaysLeft');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'accessDaysLeft');
|
|
||||||
}
|
|
||||||
if (type === 'offer') {
|
|
||||||
setupgradeNotificationCurrentState('FPDdaysLeft');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'FPDdaysLeft');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expirationText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationDays"
|
|
||||||
defaultMessage={`{dayCount, number} {dayCount, plural,
|
|
||||||
one {day}
|
|
||||||
other {days}} left`}
|
|
||||||
values={{
|
|
||||||
dayCount: (Math.floor(hoursToExpiration / 24)),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (hoursToExpiration >= 1) { // More than 1 hour left
|
|
||||||
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
|
|
||||||
if (setupgradeNotificationCurrentState) {
|
|
||||||
if (type === 'access') {
|
|
||||||
setupgradeNotificationCurrentState('accessHoursLeft');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'accessHoursLeft');
|
|
||||||
}
|
|
||||||
if (type === 'offer') {
|
|
||||||
setupgradeNotificationCurrentState('FPDHoursLeft');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'FPDHoursLeft');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expirationText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationHours"
|
|
||||||
defaultMessage={`{hourCount, number} {hourCount, plural,
|
|
||||||
one {hour}
|
|
||||||
other {hours}} left`}
|
|
||||||
values={{
|
|
||||||
hourCount: (hoursToExpiration),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else { // Less than 1 hour
|
|
||||||
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
|
|
||||||
if (setupgradeNotificationCurrentState) {
|
|
||||||
if (type === 'access') {
|
|
||||||
setupgradeNotificationCurrentState('accessLastHour');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'accessLastHour');
|
|
||||||
}
|
|
||||||
if (type === 'offer') {
|
|
||||||
setupgradeNotificationCurrentState('FPDLastHour');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'FPDLastHour');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expirationText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expirationMinutes"
|
|
||||||
defaultMessage="Less than 1 hour left"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (<div className="upsell-warning">{expirationText}</div>);
|
|
||||||
};
|
|
||||||
|
|
||||||
ExpirationCountdown.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
hoursToExpiration: PropTypes.number.isRequired,
|
|
||||||
setupgradeNotificationCurrentState: PropTypes.func,
|
|
||||||
type: PropTypes.string,
|
|
||||||
};
|
|
||||||
ExpirationCountdown.defaultProps = {
|
|
||||||
setupgradeNotificationCurrentState: null,
|
|
||||||
type: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccessExpirationDateBanner = ({
|
|
||||||
courseId, accessExpirationDate, timezoneFormatArgs, setupgradeNotificationCurrentState,
|
|
||||||
}) => {
|
|
||||||
if (setupgradeNotificationCurrentState) {
|
|
||||||
setupgradeNotificationCurrentState('accessDateView');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'accessDateView');
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="upsell-warning-light">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.expiration"
|
|
||||||
defaultMessage="Course access will expire {date}"
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpireDate"
|
|
||||||
day="numeric"
|
|
||||||
month="long"
|
|
||||||
value={accessExpirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AccessExpirationDateBanner.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
|
|
||||||
timezoneFormatArgs: PropTypes.shape({
|
|
||||||
timeZone: PropTypes.string,
|
|
||||||
}),
|
|
||||||
setupgradeNotificationCurrentState: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
AccessExpirationDateBanner.defaultProps = {
|
|
||||||
timezoneFormatArgs: {},
|
|
||||||
setupgradeNotificationCurrentState: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PastExpirationDateBanner = ({
|
|
||||||
courseId, accessExpirationDate, timezoneFormatArgs, setupgradeNotificationCurrentState,
|
|
||||||
}) => {
|
|
||||||
if (setupgradeNotificationCurrentState) {
|
|
||||||
setupgradeNotificationCurrentState('PastExpirationDate');
|
|
||||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'PastExpirationDate');
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="upsell-warning">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.pastExpiration.banner"
|
|
||||||
defaultMessage="Upgrade deadline passed on {date}"
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpireDate"
|
|
||||||
day="numeric"
|
|
||||||
month="long"
|
|
||||||
value={accessExpirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PastExpirationDateBanner.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
|
|
||||||
timezoneFormatArgs: PropTypes.shape({
|
|
||||||
timeZone: PropTypes.string,
|
|
||||||
}),
|
|
||||||
setupgradeNotificationCurrentState: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
PastExpirationDateBanner.defaultProps = {
|
|
||||||
timezoneFormatArgs: {},
|
|
||||||
setupgradeNotificationCurrentState: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const UpgradeNotification = ({
|
|
||||||
accessExpiration,
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
marketingUrl,
|
|
||||||
courseId,
|
|
||||||
offer,
|
|
||||||
org,
|
|
||||||
setupgradeNotificationCurrentState,
|
|
||||||
shouldDisplayBorder,
|
|
||||||
timeOffsetMillis,
|
|
||||||
upsellPageName,
|
|
||||||
userTimezone,
|
|
||||||
verifiedMode,
|
|
||||||
toggleSidebar,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dateNow = Date.now();
|
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
|
||||||
const correctedTime = new Date(dateNow + timeOffsetMillis);
|
|
||||||
const accessExpirationDate = accessExpiration ? new Date(accessExpiration.expirationDate) : null;
|
|
||||||
const pastExpirationDeadline = accessExpiration ? new Date(dateNow) > accessExpirationDate : false;
|
|
||||||
|
|
||||||
const eventProperties = {
|
|
||||||
org_key: org,
|
|
||||||
courserun_key: courseId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const promotionEventProperties = {
|
|
||||||
creative: 'sidebarupsell',
|
|
||||||
name: 'In-Course Verification Prompt',
|
|
||||||
position: 'sidebar-message',
|
|
||||||
promotion_id: 'courseware_verified_certificate_upsell',
|
|
||||||
...eventProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
|
|
||||||
sendTrackEvent('Promotion Viewed', promotionEventProperties);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!verifiedMode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logClick = () => {
|
|
||||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
|
|
||||||
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
|
||||||
...eventProperties,
|
|
||||||
location: 'sidebar-message',
|
|
||||||
});
|
|
||||||
sendTrackEvent('Promotion Clicked', promotionEventProperties);
|
|
||||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
|
||||||
...eventProperties,
|
|
||||||
linkCategory: 'green_upgrade',
|
|
||||||
linkName: `${upsellPageName}_green`,
|
|
||||||
linkType: 'button',
|
|
||||||
pageName: upsellPageName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const logClickPastExpiration = () => {
|
|
||||||
sendTrackEvent('edx.bi.ecommerce.upgrade_notification.past_expiration.button_clicked', {
|
|
||||||
...eventProperties,
|
|
||||||
linkCategory: 'upgrade_notification',
|
|
||||||
linkName: `${upsellPageName}_course_details`,
|
|
||||||
linkType: 'button',
|
|
||||||
pageName: upsellPageName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
There are 5 parts that change in the upgrade card:
|
|
||||||
upgradeNotificationHeaderText
|
|
||||||
expirationBanner
|
|
||||||
upsellMessage
|
|
||||||
callToActionButton
|
|
||||||
offerCode
|
|
||||||
*/
|
|
||||||
let upgradeNotificationHeaderText;
|
|
||||||
let expirationBanner;
|
|
||||||
let upsellMessage;
|
|
||||||
let callToActionButton;
|
|
||||||
let offerCode;
|
|
||||||
|
|
||||||
if (!!accessExpiration && !!contentTypeGatingEnabled) {
|
|
||||||
const hoursToAccessExpiration = Math.floor((accessExpirationDate - correctedTime) / 1000 / 60 / 60);
|
|
||||||
|
|
||||||
if (hoursToAccessExpiration >= (7 * 24)) {
|
|
||||||
if (offer) { // countdown to the first purchase discount if there is one
|
|
||||||
const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60);
|
|
||||||
upgradeNotificationHeaderText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.firstTimeLearnerDiscount"
|
|
||||||
defaultMessage="{percentage}% First-Time Learner Discount"
|
|
||||||
values={{
|
|
||||||
percentage: (offer.percentage),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expirationBanner = (
|
|
||||||
<ExpirationCountdown
|
|
||||||
courseId={courseId}
|
|
||||||
hoursToExpiration={hoursToDiscountExpiration}
|
|
||||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
|
||||||
type="offer"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
upgradeNotificationHeaderText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.accessExpiration"
|
|
||||||
defaultMessage="Upgrade your course today"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expirationBanner = (
|
|
||||||
<AccessExpirationDateBanner
|
|
||||||
courseId={courseId}
|
|
||||||
accessExpirationDate={accessExpirationDate}
|
|
||||||
timezoneFormatArgs={timezoneFormatArgs}
|
|
||||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
upsellMessage = <UpsellFBEFarAwayCardContent />;
|
|
||||||
} else if (hoursToAccessExpiration < (7 * 24) && hoursToAccessExpiration >= 0) {
|
|
||||||
// more urgent messaging if there's less than 7 days left to access expiration
|
|
||||||
upgradeNotificationHeaderText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.accessExpirationUrgent"
|
|
||||||
defaultMessage="Course Access Expiration"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expirationBanner = (
|
|
||||||
<ExpirationCountdown
|
|
||||||
courseId={courseId}
|
|
||||||
hoursToExpiration={hoursToAccessExpiration}
|
|
||||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
|
||||||
type="access"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
upsellMessage = (
|
|
||||||
<UpsellFBESoonCardContent
|
|
||||||
accessExpirationDate={accessExpirationDate}
|
|
||||||
timezoneFormatArgs={timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else { // access expiration deadline has passed
|
|
||||||
upgradeNotificationHeaderText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.accessExpirationPast"
|
|
||||||
defaultMessage="Course Access Expiration"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expirationBanner = (
|
|
||||||
<PastExpirationDateBanner
|
|
||||||
courseId={courseId}
|
|
||||||
accessExpirationDate={accessExpirationDate}
|
|
||||||
timezoneFormatArgs={timezoneFormatArgs}
|
|
||||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
upsellMessage = (
|
|
||||||
<PastExpirationCardContent />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else { // FBE is turned off
|
|
||||||
upgradeNotificationHeaderText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.pursueAverifiedCertificate"
|
|
||||||
defaultMessage="Pursue a verified certificate"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
upsellMessage = (<UpsellNoFBECardContent />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pastExpirationDeadline) {
|
|
||||||
callToActionButton = (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={logClickPastExpiration}
|
|
||||||
href={marketingUrl}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
View Course Details
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
callToActionButton = (
|
|
||||||
<UpgradeButton
|
|
||||||
offer={offer}
|
|
||||||
onClick={logClick}
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
block
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offer) { // if there's a first purchase discount, message the code at the bottom
|
|
||||||
offerCode = (
|
|
||||||
<div className="text-center discount-info">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.generic.upgradeNotification.code"
|
|
||||||
defaultMessage="Use code {code} at checkout"
|
|
||||||
values={{
|
|
||||||
code: (<span className="font-weight-bold">{offer.code}</span>),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}>
|
|
||||||
<div id="courseHome-upgradeNotification">
|
|
||||||
<h2
|
|
||||||
className={classNames('h5 upgrade-notification-header', {
|
|
||||||
'd-flex align-items-center mr-2 ml-4 my-1.5 font-size-18': !!toggleSidebar,
|
|
||||||
})}
|
|
||||||
id="outline-sidebar-upgrade-header"
|
|
||||||
>
|
|
||||||
{upgradeNotificationHeaderText}
|
|
||||||
{!!toggleSidebar && (
|
|
||||||
<div className="d-inline-flex ml-auto">
|
|
||||||
<IconButton
|
|
||||||
src={Close}
|
|
||||||
size="sm"
|
|
||||||
iconAs={Icon}
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
className="icon-hover"
|
|
||||||
alt={intl.formatMessage(messages.close)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
{expirationBanner}
|
|
||||||
<div className="upgrade-notification-message">
|
|
||||||
{upsellMessage}
|
|
||||||
</div>
|
|
||||||
<div className="upgrade-notification-button">
|
|
||||||
{callToActionButton}
|
|
||||||
</div>
|
|
||||||
{offerCode}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
UpgradeNotification.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
org: PropTypes.string.isRequired,
|
|
||||||
accessExpiration: PropTypes.shape({
|
|
||||||
expirationDate: PropTypes.string,
|
|
||||||
}),
|
|
||||||
contentTypeGatingEnabled: PropTypes.bool,
|
|
||||||
marketingUrl: PropTypes.string,
|
|
||||||
offer: PropTypes.shape({
|
|
||||||
expirationDate: PropTypes.string,
|
|
||||||
percentage: PropTypes.number,
|
|
||||||
code: PropTypes.string,
|
|
||||||
}),
|
|
||||||
toggleSidebar: PropTypes.func,
|
|
||||||
shouldDisplayBorder: PropTypes.bool,
|
|
||||||
setupgradeNotificationCurrentState: PropTypes.func,
|
|
||||||
timeOffsetMillis: PropTypes.number,
|
|
||||||
upsellPageName: PropTypes.string.isRequired,
|
|
||||||
userTimezone: PropTypes.string,
|
|
||||||
verifiedMode: PropTypes.shape({
|
|
||||||
currencySymbol: PropTypes.string.isRequired,
|
|
||||||
price: PropTypes.number.isRequired,
|
|
||||||
upgradeUrl: PropTypes.string.isRequired,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
UpgradeNotification.defaultProps = {
|
|
||||||
accessExpiration: null,
|
|
||||||
contentTypeGatingEnabled: false,
|
|
||||||
marketingUrl: null,
|
|
||||||
offer: null,
|
|
||||||
setupgradeNotificationCurrentState: null,
|
|
||||||
shouldDisplayBorder: null,
|
|
||||||
timeOffsetMillis: 0,
|
|
||||||
userTimezone: null,
|
|
||||||
verifiedMode: null,
|
|
||||||
toggleSidebar: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(UpgradeNotification);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
.upgrade-notification {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upgrade-notification-header {
|
|
||||||
margin: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-warning {
|
|
||||||
background-color: $danger-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-warning-light {
|
|
||||||
background-color: $warning-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upsell-warning, .upsell-warning-light {
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .fa-ul added so specificity is higher than Font Awesome's .fa-ul.
|
|
||||||
// An additional Font Awesome stylesheet is imported by Braze in
|
|
||||||
// stage/production but not devstack.
|
|
||||||
.upgrade-notification-ul.fa-ul {
|
|
||||||
padding: 0.875rem 1.25rem 0;
|
|
||||||
margin: 0 0 1rem 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upgrade-notification-text {
|
|
||||||
padding: 0.875rem 1.25rem 0 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upgrade-notification-button {
|
|
||||||
padding: 1.25rem;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-info {
|
|
||||||
border-top: 1px solid $light-400;
|
|
||||||
padding-top: .75rem;
|
|
||||||
padding-bottom: .75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-size-18 {
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
|
|
||||||
import {
|
|
||||||
fireEvent,
|
|
||||||
initializeMockApp,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from '../../setupTest';
|
|
||||||
import UpgradeNotification from './UpgradeNotification';
|
|
||||||
|
|
||||||
initializeMockApp();
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
|
||||||
const dateNow = new Date('2021-04-13T11:01:58.000Z');
|
|
||||||
jest
|
|
||||||
.spyOn(global.Date, 'now')
|
|
||||||
.mockImplementation(() => dateNow.valueOf());
|
|
||||||
|
|
||||||
describe('Upgrade Notification', () => {
|
|
||||||
function buildAndRender(attributes) {
|
|
||||||
const upgradeNotificationData = Factory.build('upgradeNotificationData', { ...attributes });
|
|
||||||
render(<UpgradeNotification {...upgradeNotificationData} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('sends upgrade click info to segment', async () => {
|
|
||||||
sendTrackEvent.mockClear();
|
|
||||||
buildAndRender({ pageName: 'test' });
|
|
||||||
|
|
||||||
const upgradeButton = await waitFor(() => screen.queryByRole('link', { name: 'Upgrade for $149' }));
|
|
||||||
fireEvent.click(upgradeButton);
|
|
||||||
|
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
|
||||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.bi.ecommerce.upsell_links_clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course',
|
|
||||||
linkCategory: 'green_upgrade',
|
|
||||||
linkName: 'test_green',
|
|
||||||
linkType: 'button',
|
|
||||||
pageName: 'test',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render when there is no verified mode', async () => {
|
|
||||||
buildAndRender({ verifiedMode: null });
|
|
||||||
expect(screen.queryByRole('link', { name: 'Upgrade for $149' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders non-FBE when there is a verified mode but no FBE', async () => {
|
|
||||||
buildAndRender();
|
|
||||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders non-FBE when there is a verified mode and access expiration, but no content gating', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setMinutes(expirationDate.getMinutes() + 45);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders non-FBE when there is a verified mode and content gating, but no access expiration', async () => {
|
|
||||||
buildAndRender({
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
accessExpiration: null,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders non-FBE with a discount properly', async () => {
|
|
||||||
const discountExpirationDate = new Date(dateNow);
|
|
||||||
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
|
|
||||||
buildAndRender({
|
|
||||||
offer: {
|
|
||||||
expirationDate: discountExpirationDate.toString(),
|
|
||||||
percentage: 15,
|
|
||||||
code: 'Welcome15',
|
|
||||||
discountedPrice: '$126.65',
|
|
||||||
originalPrice: '$149',
|
|
||||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
|
||||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders FBE expiration within an hour properly', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setMinutes(expirationDate.getMinutes() + 45);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 13.');
|
|
||||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders FBE expiration within 24 hours properly', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setHours(expirationDate.getHours() + 12);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('12 hours left')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/You will lose all access to this course.*?on/s)).toHaveTextContent('You will lose all access to this course, including any progress, on April 13.');
|
|
||||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders FBE expiration within 7 days properly', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setDate(expirationDate.getDate() + 6);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('6 days left')).toBeInTheDocument(); // setting the time to 12 will mean that it's slightly less than 12
|
|
||||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 19.');
|
|
||||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders FBE expiration greater than 7 days properly', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setDate(expirationDate.getDate() + 14);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Upgrade your course today' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Course access will expire/s).textContent).toMatch('Course access will expire April 27');
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
|
||||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders discount less than an hour properly', async () => {
|
|
||||||
const accessExpirationDate = new Date(dateNow);
|
|
||||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
|
||||||
const discountExpirationDate = new Date(dateNow);
|
|
||||||
discountExpirationDate.setMinutes(discountExpirationDate.getMinutes() + 30);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: accessExpirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
offer: {
|
|
||||||
expirationDate: discountExpirationDate.toString(),
|
|
||||||
percentage: 15,
|
|
||||||
code: 'Welcome15',
|
|
||||||
discountedPrice: '$126.65',
|
|
||||||
originalPrice: '$149',
|
|
||||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
|
||||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
|
||||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders discount less than a day properly', async () => {
|
|
||||||
const accessExpirationDate = new Date(dateNow);
|
|
||||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
|
||||||
const discountExpirationDate = new Date(dateNow);
|
|
||||||
discountExpirationDate.setHours(discountExpirationDate.getHours() + 12);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: accessExpirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
offer: {
|
|
||||||
expirationDate: discountExpirationDate.toString(),
|
|
||||||
percentage: 15,
|
|
||||||
code: 'Welcome15',
|
|
||||||
discountedPrice: '$126.65',
|
|
||||||
originalPrice: '$149',
|
|
||||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/hours left/s).textContent).toMatch('12 hours left');
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
|
||||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
|
||||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders discount less a week properly', async () => {
|
|
||||||
const accessExpirationDate = new Date(dateNow);
|
|
||||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 21);
|
|
||||||
const discountExpirationDate = new Date(dateNow);
|
|
||||||
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: accessExpirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
offer: {
|
|
||||||
expirationDate: discountExpirationDate.toString(),
|
|
||||||
percentage: 15,
|
|
||||||
code: 'Welcome15',
|
|
||||||
discountedPrice: '$126.65',
|
|
||||||
originalPrice: '$149',
|
|
||||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/days left/s).textContent).toMatch('6 days left');
|
|
||||||
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
|
|
||||||
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
|
|
||||||
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
|
|
||||||
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our mission at edX');
|
|
||||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
|
||||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders discount less a week access expiration less than a week properly', async () => {
|
|
||||||
const accessExpirationDate = new Date(dateNow);
|
|
||||||
accessExpirationDate.setDate(accessExpirationDate.getDate() + 5);
|
|
||||||
const discountExpirationDate = new Date(dateNow);
|
|
||||||
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
|
|
||||||
buildAndRender({
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: accessExpirationDate.toString(),
|
|
||||||
},
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
offer: {
|
|
||||||
expirationDate: discountExpirationDate.toString(),
|
|
||||||
percentage: 15,
|
|
||||||
code: 'Welcome15',
|
|
||||||
discountedPrice: '$126.65',
|
|
||||||
originalPrice: '$149',
|
|
||||||
upgradeUrl: 'www.exampleUpgradeUrl.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('5 days left')).toBeInTheDocument(); // setting the time to 12 will mean that it's slightly less than 12
|
|
||||||
expect(screen.getByText(/You will lose all access to this course.*?on/s).textContent).toMatch('You will lose all access to this course, including any progress, on April 18.');
|
|
||||||
expect(screen.getByText(/Upgrading your course enables you/s).textContent).toMatch('Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the benefits of upgrading.');
|
|
||||||
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
|
|
||||||
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders past access expiration message properly', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setDate(expirationDate.getDate() - 1);
|
|
||||||
buildAndRender({
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(screen.getByRole('heading', { name: 'Course Access Expiration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/The upgrade deadline/s).textContent).toMatch('The upgrade deadline for this course passed');
|
|
||||||
expect(screen.getByText(/To upgrade/s).textContent).toMatch('To upgrade, enroll in the next available session');
|
|
||||||
expect(screen.getByRole('button', { name: 'View Course Details' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends course details click info to segment if past access expiration', async () => {
|
|
||||||
const expirationDate = new Date(dateNow);
|
|
||||||
expirationDate.setDate(expirationDate.getDate() - 1);
|
|
||||||
sendTrackEvent.mockClear();
|
|
||||||
buildAndRender({
|
|
||||||
pageName: 'test',
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
accessExpiration: {
|
|
||||||
expirationDate: expirationDate.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const courseDetailsLink = await waitFor(() => screen.queryByRole('button', { name: 'View Course Details' }));
|
|
||||||
fireEvent.click(courseDetailsLink);
|
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
|
||||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upgrade_notification.past_expiration.button_clicked', {
|
|
||||||
org_key: 'edX',
|
|
||||||
courserun_key: 'course-v1:edX+DemoX+Demo_Course',
|
|
||||||
linkCategory: 'upgrade_notification',
|
|
||||||
linkName: 'test_course_details',
|
|
||||||
linkType: 'button',
|
|
||||||
pageName: 'test',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -433,13 +433,10 @@
|
|||||||
// Import component-specific sass files
|
// Import component-specific sass files
|
||||||
@import "courseware/course/celebration/CelebrationModal.scss";
|
@import "courseware/course/celebration/CelebrationModal.scss";
|
||||||
@import "courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss";
|
@import "courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss";
|
||||||
@import "courseware/course/sequence/lock-paywall/LockPaywall.scss";
|
|
||||||
@import "shared/streak-celebration/StreakCelebrationModal.scss";
|
@import "shared/streak-celebration/StreakCelebrationModal.scss";
|
||||||
@import "courseware/course/content-tools/calculator/calculator.scss";
|
@import "courseware/course/content-tools/calculator/calculator.scss";
|
||||||
@import "courseware/course/content-tools/contentTools.scss";
|
@import "courseware/course/content-tools/contentTools.scss";
|
||||||
@import "course-home/dates-tab/timeline/Day.scss";
|
@import "course-home/dates-tab/timeline/Day.scss";
|
||||||
@import "generic/upgrade-notification/UpgradeNotification.scss";
|
|
||||||
@import "generic/upsell-bullets/UpsellBullets.scss";
|
|
||||||
@import "course-home/outline-tab/widgets/ProctoringInfoPanel.scss";
|
@import "course-home/outline-tab/widgets/ProctoringInfoPanel.scss";
|
||||||
@import "course-home/outline-tab/widgets/FlagButton.scss";
|
@import "course-home/outline-tab/widgets/FlagButton.scss";
|
||||||
@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss";
|
@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss";
|
||||||
|
|||||||
Reference in New Issue
Block a user