diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx index 69c3c23f..cae5616f 100644 --- a/src/components/MessageForm/index.jsx +++ b/src/components/MessageForm/index.jsx @@ -10,11 +10,14 @@ import { getChatResponse, updateCurrentMessage, } from '../../data/thunks'; +import { useCourseUpgrade } from '../../hooks'; import { usePromptExperimentDecision } from '../../experiments'; import './MessageForm.scss'; const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { + const { upgradeable } = useCourseUpgrade(); + const { apiIsLoading, currentMessage, apiError } = useSelector(state => state.learningAssistant); const dispatch = useDispatch(); const inputRef = useRef(); @@ -35,7 +38,7 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => { if (currentMessage) { dispatch(acknowledgeDisclosure(true)); dispatch(addChatMessage('user', currentMessage, courseId, promptExperimentVariationKey)); - dispatch(getChatResponse(courseId, unitId, promptExperimentVariationKey)); + dispatch(getChatResponse(courseId, unitId, upgradeable, promptExperimentVariationKey)); } }; diff --git a/src/components/MessageForm/index.test.jsx b/src/components/MessageForm/index.test.jsx index 6c8dba2b..220bfbdf 100644 --- a/src/components/MessageForm/index.test.jsx +++ b/src/components/MessageForm/index.test.jsx @@ -12,9 +12,14 @@ import { getChatResponse, updateCurrentMessage, } from '../../data/thunks'; +import { useCourseUpgrade } from '../../hooks'; import MessageForm from '.'; +jest.mock('../../hooks', () => ({ + useCourseUpgrade: jest.fn(), +})); + jest.mock('../../utils/surveyMonkey', () => ({ showControlSurvey: jest.fn(), showVariationSurvey: jest.fn(), @@ -81,6 +86,9 @@ describe('', () => { beforeEach(() => { jest.resetAllMocks(); usePromptExperimentDecision.mockReturnValue([]); + useCourseUpgrade.mockReturnValue({ + upgradeable: true, + }); }); describe('when rendered', () => { @@ -137,7 +145,7 @@ describe('', () => { expect(acknowledgeDisclosure).toHaveBeenCalledWith(true); expect(addChatMessage).toHaveBeenCalledWith('user', currentMessage, defaultProps.courseId, undefined); - expect(getChatResponse).toHaveBeenCalledWith(defaultProps.courseId, defaultProps.unitId, undefined); + expect(getChatResponse).toHaveBeenCalledWith(defaultProps.courseId, defaultProps.unitId, true, undefined); expect(mockDispatch).toHaveBeenCalledTimes(3); }); @@ -187,6 +195,7 @@ describe('', () => { expect(getChatResponse).toHaveBeenCalledWith( defaultProps.courseId, defaultProps.unitId, + true, OPTIMIZELY_PROMPT_EXPERIMENT_VARIATION_KEYS.UPDATED_PROMPT, ); expect(mockDispatch).toHaveBeenCalledTimes(3); diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss index 7dfff12e..8d385146 100644 --- a/src/components/Sidebar/Sidebar.scss +++ b/src/components/Sidebar/Sidebar.scss @@ -31,6 +31,11 @@ height: 30px; } } + + .trial-header { + font-size: 0.9em; + background-color: #F49974; + } } .separator { diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index d9bc6497..5f6425d8 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -1,20 +1,21 @@ import React, { useRef, useEffect } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; + import { Icon, IconButton, } from '@openedx/paragon'; import { Close } from '@openedx/paragon/icons'; -import { useCourseUpgrade } from '../../hooks'; import showSurvey from '../../utils/surveyMonkey'; import APIError from '../APIError'; import ChatBox from '../ChatBox'; import Disclosure from '../Disclosure'; -import UpgradePanel from '../UpgradePanel'; import MessageForm from '../MessageForm'; +import UpgradePanel from '../UpgradePanel'; +import { useCourseUpgrade, useTrackEvent } from '../../hooks'; import { ReactComponent as XpertLogo } from '../../assets/xpert-logo.svg'; import './Sidebar.scss'; @@ -30,7 +31,11 @@ const Sidebar = ({ messageList, } = useSelector(state => state.learningAssistant); - const { upgradeable, auditTrialExpired } = useCourseUpgrade(); + const { + upgradeable, upgradeUrl, auditTrialExpired, auditTrialDaysRemaining, + } = useCourseUpgrade(); + + const { track } = useTrackEvent(); const chatboxContainerRef = useRef(null); @@ -80,11 +85,53 @@ const Sidebar = ({ ); + const handleUpgradeLinkClick = () => { + track('edx.ui.lms.learning_assistant.days_remaining_banner_upgrade_click'); + }; + + const getUpgradeLink = () => ( + + Upgrade + + ); + + const getDaysRemainingMessage = () => { // eslint-disable-line consistent-return + if (auditTrialDaysRemaining > 1) { + const intlRelativeTime = new Intl.RelativeTimeFormat({ style: 'long' }); + return ( +
+ Your trial ends {intlRelativeTime.format(auditTrialDaysRemaining, 'day')}. {getUpgradeLink()} for full access to Xpert. +
+ ); + } if (auditTrialDaysRemaining === 1) { + return ( +
+ Your trial ends today! {getUpgradeLink()} for full access to Xpert. +
+ ); + } + }; + const getSidebar = () => ( -
+
+ {upgradeable + && ( +
+ {getDaysRemainingMessage()} +
+ )} ({ + useCourseUpgrade: jest.fn(), + useTrackEvent: jest.fn(), +})); + jest.mock('../../utils/surveyMonkey', () => jest.fn()); jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -34,11 +41,6 @@ jest.mock('../../experiments', () => ({ usePromptExperimentDecision: jest.fn(), })); -jest.mock('../../hooks', () => ({ - useCourseUpgrade: jest.fn(), - useTrackEvent: jest.fn(), -})); - const defaultProps = { courseId: 'some-course-id', isOpen: true, @@ -67,11 +69,12 @@ const render = async (props = {}, sliceState = {}) => { }; describe('', () => { - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); useCourseUpgrade.mockReturnValue({ upgradeable: false }); - useTrackEvent.mockReturnValue({ track: jest.fn() }); usePromptExperimentDecision.mockReturnValue([]); + useCourseUpgrade.mockReturnValue([]); + useTrackEvent.mockReturnValue({ track: jest.fn() }); }); describe('when it\'s open', () => { @@ -90,13 +93,57 @@ describe('', () => { expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument(); }); - it('should not render xpert if audit trial is expired', () => { + it('If auditTrialDaysRemaining > 1, show days remaining', () => { useCourseUpgrade.mockReturnValue({ upgradeable: true, - auditTrialExpired: true, + upgradeUrl: 'https://mockurl.com', + auditTrialDaysRemaining: 2, }); - render(); - expect(screen.queryByTestId('sidebar-xpert')).not.toBeInTheDocument(); + render(undefined, { disclosureAcknowledged: true }); + expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument(); + expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument(); + expect(screen.queryByTestId('days-remaining-message')).toBeInTheDocument(); + }); + + it('If auditTrialDaysRemaining === 1, say final day', () => { + useCourseUpgrade.mockReturnValue({ + upgradeable: true, + upgradeUrl: 'https://mockurl.com', + auditTrialDaysRemaining: 1, + }); + render(undefined, { disclosureAcknowledged: true }); + expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument(); + expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument(); + expect(screen.queryByTestId('trial-ends-today')).not.toBeInTheDocument(); + }); + + it('should call track event on click', () => { + useCourseUpgrade.mockReturnValue({ + upgradeable: true, + upgradeUrl: 'https://mockurl.com', + auditTrialDaysRemaining: 1, + }); + const mockedTrackEvent = jest.fn(); + useTrackEvent.mockReturnValue({ track: mockedTrackEvent }); + + render(undefined, { disclosureAcknowledged: true }); + const upgradeLink = screen.queryByTestId('days_remaining_banner_upgrade_link'); + expect(mockedTrackEvent).not.toHaveBeenCalled(); + fireEvent.click(upgradeLink); + expect(mockedTrackEvent).toHaveBeenCalledWith('edx.ui.lms.learning_assistant.days_remaining_banner_upgrade_click'); + }); + + it('If auditTrialDaysRemaining < 1, do not show either of those', () => { + useCourseUpgrade.mockReturnValue({ + upgradeable: true, + upgradeUrl: 'https://mockurl.com', + auditTrialDaysRemaining: 0, + }); + render(undefined, { disclosureAcknowledged: true }); + expect(screen.queryByTestId('sidebar-xpert')).toBeInTheDocument(); + expect(screen.queryByTestId('get-days-remaining-message')).toBeInTheDocument(); + expect(screen.queryByTestId('days-remaining-message')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trial-ends-today')).not.toBeInTheDocument(); }); }); diff --git a/src/data/thunks.js b/src/data/thunks.js index 31d71844..ac069283 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -1,6 +1,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { camelCaseObject } from '@edx/frontend-platform'; import trackChatBotMessageOptimizely from '../utils/optimizelyExperiment'; import { fetchChatResponse, @@ -55,7 +56,7 @@ export function addChatMessage(role, content, courseId, promptExperimentVariatio }; } -export function getChatResponse(courseId, unitId, promptExperimentVariationKey = undefined) { +export function getChatResponse(courseId, unitId, upgradeable, promptExperimentVariationKey = undefined) { return async (dispatch, getState) => { const { userId } = getAuthenticatedUser(); const { messageList } = getState().learningAssistant; @@ -68,6 +69,13 @@ export function getChatResponse(courseId, unitId, promptExperimentVariationKey = const customQueryParams = promptExperimentVariationKey ? { responseVariation: promptExperimentVariationKey } : {}; const message = await fetchChatResponse(courseId, messageList, unitId, customQueryParams); + // Refresh chat summary only on the first message for an upgrade eligible user + // so we can tell if the user has just initiated an audit trial + if (messageList.length === 1 && upgradeable) { + // eslint-disable-next-line no-use-before-define + dispatch(getLearningAssistantChatSummary(courseId)); + } + dispatch(setApiIsLoading(false)); dispatch(addChatMessage(message.role, message.content, courseId, promptExperimentVariationKey)); } catch (error) { @@ -129,11 +137,19 @@ export function getLearningAssistantChatSummary(courseId) { } // Audit Trial - const auditTrial = data.audit_trial; - - // If returned audit trial data is not empty - if (Object.keys(auditTrial).length !== 0) { - dispatch(setAuditTrial(auditTrial)); + const auditTrial = { + startDate: data.audit_trial.start_date, + expirationDate: data.audit_trial.expiration_date, + }; + + // Validate audit trial data & dates + const auditTrialDatesValid = !( + Number.isNaN(Date.parse(auditTrial.startDate)) + || Number.isNaN(Date.parse(auditTrial.expirationDate)) + ); + + if (Object.keys(auditTrial).length !== 0 && auditTrialDatesValid) { + dispatch(setAuditTrial(camelCaseObject(auditTrial))); } if (data.audit_trial_length_days) { dispatch(setAuditTrialLengthDays(data.audit_trial_length_days)); } diff --git a/src/data/thunks.test.js b/src/data/thunks.test.js index 6214cc3f..8692bd95 100644 --- a/src/data/thunks.test.js +++ b/src/data/thunks.test.js @@ -26,7 +26,8 @@ describe('Thunks unit tests', () => { describe('addChatMessage()', () => { const mockDate = new Date(2024, 1, 1); - beforeAll(() => { + + beforeEach(async () => { jest.useFakeTimers('modern'); jest.setSystemTime(mockDate); }); @@ -110,8 +111,8 @@ describe('Thunks unit tests', () => { expect(dispatch).toHaveBeenNthCalledWith(5, { type: 'learning-assistant/setAuditTrial', payload: { - start_date: '2024-12-02T14:59:16.148236Z', - expiration_date: '9999-12-16T14:59:16.148236Z', + startDate: '2024-12-02T14:59:16.148236Z', + expirationDate: '9999-12-16T14:59:16.148236Z', }, }); @@ -158,8 +159,8 @@ describe('Thunks unit tests', () => { expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'learning-assistant/setAuditTrial', payload: { - start_date: '2024-12-02T14:59:16.148236Z', - expiration_date: '9999-12-16T14:59:16.148236Z', + startDate: '2024-12-02T14:59:16.148236Z', + expirationDate: '9999-12-16T14:59:16.148236Z', }, }); diff --git a/src/utils/optimizelyExperiment.js b/src/utils/optimizelyExperiment.js index 1ca3e241..c9772a82 100644 --- a/src/utils/optimizelyExperiment.js +++ b/src/utils/optimizelyExperiment.js @@ -3,7 +3,7 @@ import { getOptimizely } from '../data/optimizely'; const trackChatBotMessageOptimizely = (userId, userAttributes = {}) => { const optimizelyInstance = getOptimizely(); - if (!optimizelyInstance) { return; } + if (!optimizelyInstance || Object.keys(optimizelyInstance).length === 0) { return; } optimizelyInstance.onReady().then(() => { optimizelyInstance.track('learning_assistant_chat_message', userId, userAttributes);