Skip to content

Commit

Permalink
feat: display warning when plan is expiring
Browse files Browse the repository at this point in the history
  • Loading branch information
zwidekalanga committed Mar 14, 2024
1 parent 135cb6e commit 859753c
Show file tree
Hide file tree
Showing 17 changed files with 674 additions and 126 deletions.
21 changes: 12 additions & 9 deletions src/components/Admin/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import EmbeddedSubscription from './EmbeddedSubscription';
import { withLocation, withParams } from '../../hoc';
import AIAnalyticsSummary from './AIAnalyticsSummary';
import AIAnalyticsSummarySkeleton from './AIAnalyticsSummarySkeleton';
import BudgetExpiryComponentWrapper from '../BudgetExpiryComponent/BudgetExpiryComponentWrapper';

class Admin extends React.Component {
componentDidMount() {
Expand Down Expand Up @@ -308,6 +309,9 @@ class Admin extends React.Component {
<>
<Helmet title="Learner Progress Report" />
<Hero title="Learner Progress Report" />
<div className="mt-4">
<BudgetExpiryComponentWrapper enterpriseId={enterpriseId} />
</div>
<div className="container-fluid">
<div className="row mt-4">
<div className="col">
Expand Down Expand Up @@ -367,22 +371,21 @@ class Admin extends React.Component {
<div className="col-12 col-md-6 col-xl-4 pt-1 pb-3">
{lastUpdatedDate
&& (
<>
Showing data as of {formatTimestamp({ timestamp: lastUpdatedDate })}
</>
<>
Showing data as of {formatTimestamp({ timestamp: lastUpdatedDate })}
</>
)}

</div>
<div className="col-12 col-md-6 col-xl-8">
{this.renderDownloadButton()}
</div>
</div>
{this.displaySearchBar() && (
<AdminSearchForm
searchParams={searchParams}
searchEnrollmentsList={() => this.props.searchEnrollmentsList()}
tableData={this.getTableData() ? this.getTableData().results : []}
/>
<AdminSearchForm
searchParams={searchParams}
searchEnrollmentsList={() => this.props.searchEnrollmentsList()}
tableData={this.getTableData() ? this.getTableData().results : []}
/>
)}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import BudgetExpiryComponent from './index';
import { useEnterpriseBudgets } from '../EnterpriseSubsidiesContext/data/hooks';

const BudgetExpiryComponentWrapper = ({ enterpriseUUID, enterpriseFeatures }) => {
const {
data: budgetOverview,
} = useEnterpriseBudgets({
isTopDownAssignmentEnabled: enterpriseFeatures.topDownAssignmentRealTimeLcm,
enterpriseId: enterpriseUUID,
enablePortalLearnerCreditManagementScreen: true,
});

return (
<BudgetExpiryComponent budgets={budgetOverview.budgets} />
);
};

BudgetExpiryComponentWrapper.propTypes = {
enterpriseUUID: PropTypes.string.isRequired,
enterpriseFeatures: PropTypes.shape({
topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired,
}),
};

const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
enterpriseFeatures: state.portalConfiguration.enterpriseFeatures,
});

export default connect(mapStateToProps)(BudgetExpiryComponentWrapper);
3 changes: 3 additions & 0 deletions src/components/BudgetExpiryComponent/data/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PLAN_EXPIRY_MODAL_TITLE = 'Plan expiry model';

export const SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX = 'seen-enterprise-expiration-modal-';
62 changes: 62 additions & 0 deletions src/components/BudgetExpiryComponent/data/expiryThresholds.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"120": {
"notificationTemplate": {
"title": "Your Learner Credit plan is ending soon",
"variant": "info",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning.",
"dismissible": true
},
"modalTemplate": {
"title": "Your plan’s end date is approaching",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning."
}
},
"90": {
"notificationTemplate": {
"title": "Reminder: Your plan’s end date is approaching",
"variant": "info",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning.",
"dismissible": true
},
"modalTemplate": {
"title": "Reminder: Your Learner Credit plan is ending soon",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning."
}
},
"60": {
"notificationTemplate": {
"title": "Your Learner Credit plan expires {{date}}",
"variant": "warning",
"message": "When your Learner Credit plan expires, you will no longer have access to administrative functions and the remaining balance of your budget(s) will be unusable. Contact a representative today to renew your plan.",
"dismissible": true
},
"modalTemplate": {
"title": "Your Learner Credit plan expires {{date}}",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning."
}
},
"30": {
"notificationTemplate": {
"title": "Your Learner Credit plan expires in less than 30 days",
"variant": "danger",
"message": "When your plan expires you will lose access to administrative functions and the remaining balance of your plan’s budget(s) will be unusable. Contact your representative today to renew your plan.",
"dismissible": false
},
"modalTemplate": {
"title": "Your Learner Credit plan expires in less than 30 days",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning."
}
},
"10": {
"notificationTemplate": {
"title": "Reminder: Your Learner Credit plan expires {{date}}",
"variant": "danger",
"message": "Your Learner Credit plan expires in {{days}} days, {{hours}} hours, and {{minutes}} minutes. Contact your representative today to renew your plan.",
"dismissible": false
},
"modalTemplate": {
"title": "Reminder: Your Learner Credit plan expires {{date}}",
"message": "Your Learner Credit plan expires {{date}}. Contact your representative today to renew your plan and keep people learning."
}
}
}
66 changes: 66 additions & 0 deletions src/components/BudgetExpiryComponent/data/hooks/useExpiry.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react';
import { getEnterpriseBudgetExpiringCookieName, isPlanApproachingExpiry } from '../utils';

const useExpiry = (enterpriseId, budgets, modalOpen, modalClose) => {
const [isExpiring, setIsExpiring] = useState(false);
const [notification, setNotification] = useState(null);
const [expirationThreshold, setExpirationThreshold] = useState(null);
const [modal, setModal] = useState(null);

useEffect(() => {
if (!budgets || budgets.length === 0) {
return;
}

// Find the budget with the earliest expiry date
const earliestExpiryBudget = budgets.reduce(
(earliestBudget, currentBudget) => (currentBudget.end < earliestBudget.end ? currentBudget : earliestBudget),
budgets[0],
);

// Determine the notification based on the expiry date
const { isPlanExpiring, thresholdKey, threshold } = isPlanApproachingExpiry(earliestExpiryBudget.end);

setExpirationThreshold({
isPlanExpiring,
thresholdKey,
threshold,
});

const seenCurrentExpiringModalCookieName = getEnterpriseBudgetExpiringCookieName({
expirationThreshold: thresholdKey,
enterpriseId,
});

const isDismissed = global.localStorage.getItem(seenCurrentExpiringModalCookieName);

if (isPlanExpiring) {
const { notificationTemplate, modalTemplate } = threshold;

setIsExpiring(isPlanExpiring);
setNotification(notificationTemplate);
setModal(modalTemplate);

if (!isDismissed) {
modalOpen();
}
}
}, [budgets, enterpriseId, isExpiring, modalOpen]);

const dismissModal = () => {
const seenCurrentExpirationModalCookieName = getEnterpriseBudgetExpiringCookieName({
expirationThreshold: expirationThreshold.thresholdKey,
enterpriseId,
});

global.localStorage.setItem(seenCurrentExpirationModalCookieName, true);

modalClose();
};

return {
isExpiring, notification, modal, dismissModal,
};
};

export default useExpiry;
53 changes: 53 additions & 0 deletions src/components/BudgetExpiryComponent/data/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX } from './constants';
import ExpiryThresholds from './expiryThresholds.json';

dayjs.extend(duration);

export const getEnterpriseBudgetExpiringCookieName = ({
expirationThreshold, enterpriseId,
}) => `${SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX}${expirationThreshold}-${enterpriseId}`;

export const replacePlaceholders = (template, replacements) => {
const replacedTemplate = { ...template };

Object.keys(replacedTemplate).forEach(key => {
const regex = /{{(.*?)}}/g;
replacedTemplate[key].title = replacedTemplate[key].title.replace(regex, (_, match) => replacements[match.trim()]);
replacedTemplate[key].message = replacedTemplate[key].message.replace(
regex,
(_, match) => replacements[match.trim()],
);
});

return replacedTemplate;
};

export const isPlanApproachingExpiry = (endDateStr) => {
const x = dayjs(endDateStr);
const y = dayjs();
const durationDiff = dayjs.duration(x.diff(y));

// Find the appropriate threshold
const thresholdKeys = Object.keys(ExpiryThresholds).map(Number).sort((a, b) => a - b);
const thresholdKey = thresholdKeys.find((key) => durationDiff.asDays() <= key && durationDiff.asDays() >= 0);

if (!thresholdKey) {
return {
isExpiring: false,
threshold: {},
};
}

return {
isPlanExpiring: true,
thresholdKey,
threshold: replacePlaceholders(ExpiryThresholds[thresholdKey], {
date: x.format('MMM D, YYYY'),
days: durationDiff.days(),
hours: durationDiff.hours(),
minutes: durationDiff.minutes(),
}),
};
};
119 changes: 119 additions & 0 deletions src/components/BudgetExpiryComponent/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React from 'react';
import {
ActionRow,
Alert,
Button, Hyperlink,
ModalDialog,
useToggle,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';
import useExpiry from './data/hooks/useExpiry';
import ContactRepresentative from '../ContactRepresentative';
import { PLAN_EXPIRY_MODAL_TITLE } from './data/constants';
import EVENT_NAMES from '../../eventTracking';
import { configuration } from '../../config';

const BudgetExpiryComponent = ({ enterpriseUUID, budgets }) => {
const [contactIsOpen, contactOpen, contactClose] = useToggle(false);
const [modalIsOpen, modalOpen, modalClose] = useToggle(false);
const [alertIsOpen, , alertClose] = useToggle(true);

const supportUrl = configuration.ENTERPRISE_SUPPORT_URL;

const {
isExpiring, notification, modal, dismissModal,
} = useExpiry(
enterpriseUUID,
budgets,
modalOpen,
modalClose,
);

const trackEventMetadata = {};
if (isExpiring) {
Object.assign(
trackEventMetadata,
{
isExpiring,
notification,
modal,
},
);
}

return (
<>
{isExpiring && (
<Alert
variant={notification.variant}
show={alertIsOpen}
actions={[
<Button
as={Hyperlink}
destination={supportUrl}
onClick={() => sendEnterpriseTrackEvent(
enterpriseUUID,
EVENT_NAMES.BUDGET_EXPIRY.BUDGET_EXPIRY_ALERT_CONTACT_REPRESENTATIVE,
trackEventMetadata,
)}
>
Contact representative
</Button>,
]}
dismissible={notification.dismissible}
closeLabel="Dismiss"
onClose={() => alertClose()}
>
<Alert.Heading>{notification.title}</Alert.Heading>
<p>{notification.message}</p>
</Alert>
)}

{isExpiring && (
<ContactRepresentative isOpen={contactIsOpen} close={contactClose} />
)}

{isExpiring && (
<ModalDialog
title={PLAN_EXPIRY_MODAL_TITLE}
onClose={dismissModal}
isOpen={modalIsOpen}
>
<ModalDialog.Header className="border-bottom">
<ModalDialog.Title as="h3">
{modal.title}
</ModalDialog.Title>
</ModalDialog.Header>

<ModalDialog.Body className="font-weight-light p-4">
<p>{modal.message}</p>
</ModalDialog.Body>

<ModalDialog.Footer className="border-top">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">Dismiss</ModalDialog.CloseButton>
<Button variant="primary" onClick={contactOpen}>Contact representative</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
)}
</>
);
};

const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
});

BudgetExpiryComponent.propTypes = {
enterpriseUUID: PropTypes.string.isRequired,
budgets: PropTypes.arrayOf(
PropTypes.shape({
end: PropTypes.string.isRequired,
}),
).isRequired,
};

export default connect(mapStateToProps)(BudgetExpiryComponent);
Loading

0 comments on commit 859753c

Please sign in to comment.