Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Holt
82e26623ac Added timeOffsetMillis to outline API; not yet tested 2021-03-18 11:57:28 -04:00
Ben Holt
8edebbfd85 Compute offset to correct browser time for accurate expiration countdown 2021-03-18 09:30:52 -04:00
Emma Green
250b118c50 add access expiration stuff 2021-03-18 09:29:09 -04:00
Emma Green
e86f7b7b60 static expiration box 2021-03-18 09:27:49 -04:00
Emma Green
152931636d snapshot and linting 2021-03-16 18:23:45 -04:00
Emma Green
1f7b875ea6 static expiration box 2021-03-16 17:25:13 -04:00
5 changed files with 393 additions and 44 deletions

View File

@@ -429,6 +429,7 @@ Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",

View File

@@ -157,8 +157,12 @@ export async function getProctoringInfoData(courseId) {
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
@@ -168,6 +172,18 @@ export async function getOutlineTabData(courseId) {
throw error;
}
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
const headerDate = tabData.headers.date;
let timeOffsetMillis = 0;
if (headerDate !== undefined) {
const headerTime = Date.parse(headerDate);
const roundTripMillis = requestTime - responseTime;
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
timeOffsetMillis = headerTime - localTime;
}
const {
data,
} = tabData;
@@ -187,6 +203,7 @@ export async function getOutlineTabData(courseId) {
const welcomeMessageHtml = data.welcome_message_html;
return {
timeOffsetMillis, // This should move to a global time correction reference
accessExpiration,
canShowUpgradeSock,
courseBlocks,

View File

@@ -1,22 +1,241 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Icon } from '@edx/paragon';
import { Check } from '@edx/paragon/icons';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { UpgradeButton } from '../../../generic/upgrade-button';
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
function UpgradeCard({ courseId, intl, onLearnMore }) {
function UpsellNoFBECardContent() {
return (
<ul className="fa-ul">
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.verifiedCertLink"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{
verifiedCertLink: (
<a className="inline-link-underline" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>verified certificate</a>
),
}}
/>
</li>
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX"
values={{
nonProfitMission: (
<span className="font-weight-bold">non-profit mission</span>
),
}}
/>
</li>
</ul>
);
}
function UpsellFBEFarAwayCardContent() {
return (
<ul className="fa-ul">
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.verifiedCertLink"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{
verifiedCertLink: (
<a className="inline-link-underline" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>verified certificate</a>
),
}}
/>
</li>
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.unlock-graded"
defaultMessage="Unlock your access to all course activities, including {gradedAssignments}"
values={{
gradedAssignments: (
<span className="font-weight-bold">graded assignments</span>
),
}}
/>
</li>
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.fullAccess"
defaultMessage="{fullAccess} to course content and materials, even after the course ends"
values={{
fullAccess: (
<span className="font-weight-bold">Full access</span>
),
}}
/>
</li>
<li>
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.alert.upgradecard.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX"
values={{
nonProfitMission: (
<span className="font-weight-bold">non-profit mission</span>
),
}}
/>
</li>
</ul>
);
}
function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs }) {
return (
<div>
<p>
<FormattedMessage
id="learning.outline.alert.upgradecard.expirationAccessLoss"
defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}."
values={{
includingAnyProgress: (<span className="font-weight-bold">including any progress</span>),
date: (
<FormattedDate
key="accessDate"
day="numeric"
month="long"
value={new Date(accessExpirationDate)}
{...timezoneFormatArgs}
/>
),
}}
/>
</p>
<p>
<FormattedMessage
id="learning.outline.alert.upgradecard.expirationVerifiedCert"
defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}."
values={{
benefitsOfUpgrading: (<a className="inline-link-underline" 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-">benefits of upgrading</a>),
}}
/>
</p>
</div>
);
}
UpsellFBESoonCardContent.propTypes = {
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
timezoneFormatArgs: PropTypes.shape({
timeZone: PropTypes.string,
}),
};
UpsellFBESoonCardContent.defaultProps = {
timezoneFormatArgs: {},
};
function ExpirationCountdown({ hoursToExpiration }) {
let expirationText;
if (hoursToExpiration >= 24) {
expirationText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.expiration.days"
defaultMessage={`{dayCount, number} {dayCount, plural,
one {day}
other {days}} left`}
values={{
dayCount: (Math.floor(hoursToExpiration / 24)),
}}
/>
);
} else if (hoursToExpiration >= 1) {
expirationText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.expiration.hours"
defaultMessage={`{hourCount, number} {hourCount, plural,
one {hour}
other {hours}} left`}
values={{
hourCount: (hoursToExpiration),
}}
/>
);
} else {
expirationText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.expiration.minutes"
defaultMessage="Less than 1 hour left"
/>
);
}
return (<div className="p-3 upsell-warning">{expirationText}</div>);
}
ExpirationCountdown.propTypes = {
hoursToExpiration: PropTypes.number.isRequired,
};
function AccessExpirationDateBanner({ accessExpirationDate, timezoneFormatArgs }) {
return (
<div className="p-3 upsell-warning-light">
<FormattedMessage
id="learning.outline.alert.upgradecard.expirationr"
defaultMessage="Course access will expire {date}"
values={{
date: (
<FormattedDate
key="accessExpireDate"
day="numeric"
month="long"
value={accessExpirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</div>
);
}
AccessExpirationDateBanner.propTypes = {
accessExpirationDate: PropTypes.PropTypes.instanceOf(Date).isRequired,
timezoneFormatArgs: PropTypes.shape({
timeZone: PropTypes.string,
}),
};
AccessExpirationDateBanner.defaultProps = {
timezoneFormatArgs: {},
};
function UpgradeCard({ courseId }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
offer,
verifiedMode,
accessExpiration,
datesBannerInfo: {
contentTypeGatingEnabled,
},
datesWidget: {
userTimezone,
},
timeOffsetMillis,
} = useModel('outline', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const correctedTime = new Date(Date.now() + timeOffsetMillis);
if (!verifiedMode) {
return null;
}
@@ -34,6 +253,28 @@ function UpgradeCard({ courseId, intl, onLearnMore }) {
...eventProperties,
};
function expirationHighlight(hoursToExpiration){
let expirationText;
if(hoursToExpiration < 24){
expirationText= <FormattedMessage
id="learning.outline.alert.upgradecard.expiration"
defaultMessage="{expiration} hours left"
values={{
expiration: (hoursToDiscountExpiration),
}}
/>
} else {
expirationText =<FormattedMessage
id="learning.outline.alert.upgradecard.expiration"
defaultMessage="{expiration} days left"
values={{
expiration: (Math.floor(hoursToExpiration/24)),
}}
/>
}
return(<div className="p-3 upsell-warning">{expirationText}</div>)
}
useEffect(() => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
sendTrackEvent('Promotion Viewed', promotionEventProperties);
@@ -55,53 +296,110 @@ function UpgradeCard({ courseId, intl, onLearnMore }) {
});
};
return (
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
<div className="row w-100 m-0">
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
<img
alt={intl.formatMessage(messages.certAlt)}
className="w-100"
src={VerifiedCert}
style={{ maxWidth: '10rem' }}
/*
There are 4 parts that change in the upgrade card:
upgradeCardHeaderText
expirationBanner
upsellMessage
offerCode
*/
let upgradeCardHeaderText;
let expirationBanner;
let upsellMessage;
let offerCode;
if (!!accessExpiration && !!contentTypeGatingEnabled) {
if (offer) { // if there's a first purchase discount, show it
const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60);
upgradeCardHeaderText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.firstTimeLearnerDiscount"
defaultMessage="{percentage}% First-Time Learner Discount"
values={{
percentage: (offer.percentage),
}}
/>
);
expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToDiscountExpiration} />;
upsellMessage = <UpsellFBEFarAwayCardContent />;
offerCode = (
<div className="bg-light p-3 text-center discount-info">
<FormattedMessage
id="learning.outline.alert.upgradecard.code"
defaultMessage="Use code {code} at checkout"
values={{
code: (<span className="font-weight-bold">{offer.code}</span>),
}}
/>
</div>
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
<div className="row w-100 m-0 justify-content-center">
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
/>
{onLearnMore && (
<div className="col-12">
<Button
variant="link"
size="sm"
className="pb-0"
onClick={onLearnMore}
aria-labelledby="outline-sidebar-upgrade-header"
>
{intl.formatMessage(messages.learnMore)}
</Button>
</div>
)}
</div>
</div>
);
} else {
const accessExpirationDate = new Date(accessExpiration.expirationDate);
const hoursToAccessExpiration = Math.floor((accessExpirationDate - correctedTime) / 1000 / 60 / 60);
if (hoursToAccessExpiration >= (7 * 24)) {
upgradeCardHeaderText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.accessExpiration"
defaultMessage="Upgrade your course today"
/>
);
expirationBanner = (
<AccessExpirationDateBanner
accessExpirationDate={accessExpirationDate}
timezoneFormatArgs={timezoneFormatArgs}
/>
);
upsellMessage = <UpsellFBEFarAwayCardContent />;
} else { // more urgent messaging if there's less than 7 days left
upgradeCardHeaderText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.accessExpirationUrgent"
defaultMessage="Course Access Expiration"
/>
);
expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToAccessExpiration} />;
upsellMessage = (
<UpsellFBESoonCardContent
accessExpirationDate={accessExpirationDate}
timezoneFormatArgs={timezoneFormatArgs}
/>
);
}
}
} else { // FBE is turned off
upgradeCardHeaderText = (
<FormattedMessage
id="learning.outline.alert.upgradecard.pursueAverifiedCertificate"
defaultMessage="Pursue a verified certificate"
/>
);
upsellMessage = (<UpsellNoFBECardContent />);
}
return (
<section className="mb-4 card">
<h2 className="h5 m-3" id="outline-sidebar-upgrade-header">
{upgradeCardHeaderText}
</h2>
{expirationBanner}
<div className="p-3">
{upsellMessage}
</div>
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
className="ml-3 mr-3 mb-3"
/>
{offerCode}
</section>
);
}
UpgradeCard.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLearnMore: PropTypes.func,
};
UpgradeCard.defaultProps = {
onLearnMore: null,
};
export default injectIntl(UpgradeCard);

View File

@@ -2,3 +2,19 @@
border: 1px solid $dark-500;
border-top: 5px solid $dark-500;
}
.upsell-warning{
background-color: #FCF1F4;
}
.upsell-warning-light{
background-color: #FFFAED;;
}
.discount-info {
border-top: 1px solid rgba(0, 0, 0, 0.125);
}
.inline-link-underline {
text-decoration: underline;
}

View File

@@ -118,6 +118,7 @@ function normalizeTabUrls(id, tabs) {
function normalizeMetadata(metadata) {
return {
timeOffsetMillis: metadata.timeOffsetMillis, // This should move to a global time correction reference
accessExpiration: camelCaseObject(metadata.access_expiration),
canShowUpgradeSock: metadata.can_show_upgrade_sock,
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
@@ -156,7 +157,23 @@ function normalizeMetadata(metadata) {
export async function getCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
const requestTime = Date.now();
const { data, headers } = await getAuthenticatedHttpClient().get(url);
const responseTime = Date.now();
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
const headerDate = headers.date;
let timeOffsetMillis = 0;
if (headerDate !== undefined) {
const headerTime = Date.parse(headerDate);
const roundTripMillis = requestTime - responseTime;
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
timeOffsetMillis = headerTime - localTime;
}
data.timeOffsetMillis = timeOffsetMillis; // This should move to a global time correction reference
return normalizeMetadata(data);
}