-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: display warning when plan is expiring
- Loading branch information
1 parent
135cb6e
commit 859753c
Showing
17 changed files
with
674 additions
and
126 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/components/BudgetExpiryComponent/BudgetExpiryComponentWrapper.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
62
src/components/BudgetExpiryComponent/data/expiryThresholds.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
66
src/components/BudgetExpiryComponent/data/hooks/useExpiry.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}), | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Oops, something went wrong.