From bcfbe2489d00fbb8001c2a18e9d4458850e9b248 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 8 Oct 2024 10:59:55 -0400 Subject: [PATCH] feat: update course about sidebar to take into account usd fixed price (#1199) --- .../hooks/useCourseRedemptionEligibility.js | 17 ++- src/components/course/CourseSidebarPrice.jsx | 35 +++--- src/components/course/data/hooks.jsx | 28 +++-- .../course/data/tests/hooks.test.jsx | 108 ++++++++++++------ .../course/data/tests/utils.test.jsx | 10 +- src/components/course/data/utils.jsx | 36 ++++-- .../tests/ExternalCourseEnrollment.test.jsx | 10 +- ...ernalCourseEnrollmentConfirmation.test.jsx | 2 +- .../course/tests/CourseSidebarPrice.test.jsx | 34 +++--- .../dashboard/SubscriptionExpirationModal.jsx | 13 ++- .../components/CourseSummaryCard.jsx | 12 +- .../components/RegistrationSummaryCard.jsx | 13 +-- .../microlearning/VideoDetailPage.jsx | 16 +-- .../tests/VideoDetailPage.test.jsx | 19 ++- src/utils/common.js | 15 +++ 15 files changed, 228 insertions(+), 140 deletions(-) diff --git a/src/components/app/data/hooks/useCourseRedemptionEligibility.js b/src/components/app/data/hooks/useCourseRedemptionEligibility.js index 89e6ab42d5..5b587310ca 100644 --- a/src/components/app/data/hooks/useCourseRedemptionEligibility.js +++ b/src/components/app/data/hooks/useCourseRedemptionEligibility.js @@ -6,6 +6,21 @@ import { queryCanRedeem } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; import useLateEnrollmentBufferDays from './useLateEnrollmentBufferDays'; +const getContentListPriceRange = ({ courseRuns }) => { + const flatContentPrice = courseRuns.flatMap(run => run.listPrice?.usd).filter(x => !!x); + // Find the max and min prices + if (!flatContentPrice.length) { + return []; + } + const maxPrice = Math.max(...flatContentPrice); + const minPrice = Math.min(...flatContentPrice); + // Heuristic for displaying the price as a range or a singular price based on runs + if (maxPrice !== minPrice) { + return [minPrice, maxPrice]; + } + return [flatContentPrice[0]]; +}; + export function transformCourseRedemptionEligibility({ courseMetadata, canRedeemData, @@ -17,7 +32,7 @@ export function transformCourseRedemptionEligibility({ const otherSubsidyAccessPolicy = canRedeemData.find( r => r.redeemableSubsidyAccessPolicy, )?.redeemableSubsidyAccessPolicy; - const listPrice = redeemabilityForActiveCourseRun?.listPrice?.usd; + const listPrice = getContentListPriceRange({ courseRuns: canRedeemData }); const hasSuccessfulRedemption = courseRunKey ? !!canRedeemData.find(r => r.contentKey === courseRunKey)?.hasSuccessfulRedemption : canRedeemData.some(r => r.hasSuccessfulRedemption); diff --git a/src/components/course/CourseSidebarPrice.jsx b/src/components/course/CourseSidebarPrice.jsx index 1dcd9c8b92..6fc10bcad0 100644 --- a/src/components/course/CourseSidebarPrice.jsx +++ b/src/components/course/CourseSidebarPrice.jsx @@ -1,20 +1,20 @@ import { Skeleton } from '@openedx/paragon'; import classNames from 'classnames'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; - -import { numberWithPrecision } from './data/utils'; import { + getContentPriceDisplay, useCanUserRequestSubsidyForCourse, useCoursePrice, useIsCourseAssigned, useUserSubsidyApplicableToCourse, } from './data'; import { + ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE, - ENTERPRISE_OFFER_SUBSIDY_TYPE, useEnterpriseCustomer, } from '../app/data'; +import { sumOfArray } from '../../utils/common'; const CourseSidebarPrice = () => { const intl = useIntl(); @@ -23,12 +23,10 @@ const CourseSidebarPrice = () => { const { isCourseAssigned } = useIsCourseAssigned(); const canRequestSubsidy = useCanUserRequestSubsidyForCourse(); const { userSubsidyApplicableToCourse } = useUserSubsidyApplicableToCourse(); - if (!coursePrice) { return ; } - - const originalPriceDisplay = numberWithPrecision(coursePrice.list); + const originalPriceDisplay = getContentPriceDisplay(coursePrice.listRange); const showOrigPrice = !enterpriseCustomer.hideCourseOriginalPrice; const crossedOutOriginalPrice = ( @@ -38,7 +36,8 @@ const CourseSidebarPrice = () => { defaultMessage="Priced reduced from:" description="Message to indicate that the price has been reduced." /> - ${originalPriceDisplay} {currency} + + {originalPriceDisplay} {currency} ); @@ -62,13 +61,13 @@ const CourseSidebarPrice = () => { ); } - const hasDiscountedPrice = coursePrice.discounted < coursePrice.list; - + const hasDiscountedPrice = coursePrice.discountedList + && sumOfArray(coursePrice.discountedList) < sumOfArray(coursePrice.listRange); // Case 2: No subsidies found but learner can request a subsidy if (!hasDiscountedPrice && canRequestSubsidy) { return ( - ${originalPriceDisplay} {currency}
+ {originalPriceDisplay} {currency}
{ if (!hasDiscountedPrice) { return ( - ${originalPriceDisplay} {currency} + {originalPriceDisplay} {currency} ); } @@ -113,22 +112,22 @@ const CourseSidebarPrice = () => { }); } } - const discountedPriceDisplay = `${numberWithPrecision(coursePrice.discounted)} ${currency}`; - + const discountedPriceDisplay = `${getContentPriceDisplay(coursePrice.discountedList)} ${currency}`; return ( <> -
0 || showOrigPrice })}> - {/* discounted > 0 means partial discount */} +
0 || showOrigPrice })}> + {/* discountedList > 0 means partial discount */} {showOrigPrice && <>{crossedOutOriginalPrice}{' '}} - {coursePrice.discounted > 0 && ( + {sumOfArray(coursePrice.discountedList) > 0 && ( <> - ${discountedPriceDisplay} + + {discountedPriceDisplay} )}
diff --git a/src/components/course/data/hooks.jsx b/src/components/course/data/hooks.jsx index f8be855bc2..ca4f37dbe6 100644 --- a/src/components/course/data/hooks.jsx +++ b/src/components/course/data/hooks.jsx @@ -36,7 +36,10 @@ import { canUserRequestSubsidyForCourse, getExternalCourseEnrollmentUrl } from ' import { createExecutiveEducationFailureMessage } from '../../executive-education-2u/ExecutiveEducation2UError'; import { SUBSIDY_TYPE } from '../../../constants'; import { + COUPON_CODE_SUBSIDY_TYPE, + determineAllocatedAssignmentsForCourse, getSubsidyToApplyForCourse, + LICENSE_SUBSIDY_TYPE, useBrowseAndRequest, useBrowseAndRequestConfiguration, useCatalogsForSubsidyRequests, @@ -48,9 +51,6 @@ import { useEnterpriseOffers, useRedeemablePolicies, useSubscriptions, - COUPON_CODE_SUBSIDY_TYPE, - LICENSE_SUBSIDY_TYPE, - determineAllocatedAssignmentsForCourse, } from '../../app/data'; import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; import { CourseContext } from '../CourseContextProvider'; @@ -173,8 +173,8 @@ export function useCoursePacingType(courseRun) { /** * @typedef {Object} CoursePrice - * @property {number} list The list price. - * @property {number} discounted The discounted price. + * @property {number[]} listRange The list price. + * @property {number[]} discountedList The discountedList price. */ /** @@ -203,30 +203,34 @@ export const useCoursePriceForUserSubsidy = ({ } const onlyListPrice = { - list: listPrice, + listRange: listPrice, }; if (userSubsidyApplicableToCourse) { const { discountType, discountValue } = userSubsidyApplicableToCourse; - let discountedPrice; + let discountedPriceList = []; if (discountType && discountType.toLowerCase() === SUBSIDY_DISCOUNT_TYPE_MAP.PERCENTAGE.toLowerCase()) { - discountedPrice = listPrice - (listPrice * (discountValue / 100)); + discountedPriceList = onlyListPrice.listRange.map( + (individualPrice) => individualPrice - (individualPrice * (discountValue / 100)), + ); } if (discountType && discountType.toLowerCase() === SUBSIDY_DISCOUNT_TYPE_MAP.ABSOLUTE.toLowerCase()) { - discountedPrice = Math.max(listPrice - discountValue, 0); + discountedPriceList = onlyListPrice.listRange.map( + (individualPrice) => Math.max(individualPrice - discountValue, 0), + ); } - if (isDefinedAndNotNull(discountedPrice)) { + if (isDefinedAndNotNull(discountedPriceList)) { return { ...onlyListPrice, - discounted: discountedPrice, + discountedList: discountedPriceList, }; } return { ...onlyListPrice, - discounted: onlyListPrice.list, + discountedList: onlyListPrice.listRange, }; } diff --git a/src/components/course/data/tests/hooks.test.jsx b/src/components/course/data/tests/hooks.test.jsx index aec4267673..896e3c1efa 100644 --- a/src/components/course/data/tests/hooks.test.jsx +++ b/src/components/course/data/tests/hooks.test.jsx @@ -34,18 +34,18 @@ import { getSubscriptionDisabledEnrollmentReasonType, transformedCourseMetadata, } from '../utils'; -import { - DISABLED_ENROLL_REASON_TYPES, - DISABLED_ENROLL_USER_MESSAGES, - REASON_USER_MESSAGES, -} from '../constants'; +import { DISABLED_ENROLL_REASON_TYPES, DISABLED_ENROLL_USER_MESSAGES, REASON_USER_MESSAGES } from '../constants'; import { mockSubscriptionLicense } from '../../tests/constants'; import * as optimizelyUtils from '../../../../utils/optimizely'; import { LICENSE_STATUS } from '../../../enterprise-user-subsidy/data/constants'; import { SUBSIDY_TYPE } from '../../../../constants'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../app/data/services/data/__factories__'; import { + COUPON_CODE_SUBSIDY_TYPE, + ENTERPRISE_OFFER_SUBSIDY_TYPE, getSubsidyToApplyForCourse, + LEARNER_CREDIT_SUBSIDY_TYPE, + LICENSE_SUBSIDY_TYPE, useBrowseAndRequest, useCatalogsForSubsidyRequests, useCouponCodes, @@ -56,10 +56,6 @@ import { useEnterpriseOffers, useRedeemablePolicies, useSubscriptions, - COUPON_CODE_SUBSIDY_TYPE, - ENTERPRISE_OFFER_SUBSIDY_TYPE, - LEARNER_CREDIT_SUBSIDY_TYPE, - LICENSE_SUBSIDY_TYPE, } from '../../../app/data'; import { CourseContext } from '../../CourseContextProvider'; @@ -792,7 +788,7 @@ describe('CoursePacingType', () => { describe('useCoursePriceForUserSubsidy', () => { it('should return the correct course price when a user subsidy is applicable with percentage discount', () => { - const listPrice = 100; + const listPrice = [100]; const userSubsidyApplicableToCourse = { discountType: 'percentage', discountValue: 10, @@ -805,11 +801,11 @@ describe('useCoursePriceForUserSubsidy', () => { userSubsidyApplicableToCourse, })); const { coursePrice } = result.current; - expect(coursePrice).toEqual({ list: 100, discounted: 90 }); + expect(coursePrice).toEqual({ listRange: [100], discountedList: [90] }); }); it('should return the correct course price when a user subsidy is applicable with unknown discount type', () => { - const listPrice = 100; + const listPrice = [100]; const userSubsidyApplicableToCourse = { discountType: 'unknown', discountValue: 100, @@ -822,11 +818,11 @@ describe('useCoursePriceForUserSubsidy', () => { userSubsidyApplicableToCourse, })); const { coursePrice } = result.current; - expect(coursePrice).toEqual({ list: 100, discounted: 100 }); + expect(coursePrice).toEqual({ listRange: [100], discountedList: [] }); }); it('should return the correct course price when a user subsidy is applicable with absolute discount', () => { - const listPrice = 150; + const listPrice = [150]; const userSubsidyApplicableToCourse = { discountType: 'absolute', discountValue: 10, @@ -839,22 +835,22 @@ describe('useCoursePriceForUserSubsidy', () => { userSubsidyApplicableToCourse, })); const { coursePrice } = result.current; - expect(coursePrice).toEqual({ list: 150, discounted: 140 }); + expect(coursePrice).toEqual({ listRange: [150], discountedList: [140] }); }); it('should return the correct course price when a user subsidy is not applicable', () => { - const listPrice = 100; + const listPrice = [100]; const userSubsidyApplicableToCourse = null; const { result } = renderHook(() => useCoursePriceForUserSubsidy({ listPrice, userSubsidyApplicableToCourse, })); const { coursePrice } = result.current; - expect(coursePrice).toEqual({ list: 100 }); + expect(coursePrice).toEqual({ listRange: [100] }); }); it('should return the correct course price for exec ed course', () => { - const listPrice = 200; + const listPrice = [200]; const userSubsidyApplicableToCourse = null; const { result } = renderHook(() => useCoursePriceForUserSubsidy({ @@ -862,11 +858,11 @@ describe('useCoursePriceForUserSubsidy', () => { userSubsidyApplicableToCourse, })); const { coursePrice } = result.current; - expect(coursePrice).toEqual({ list: 200 }); + expect(coursePrice).toEqual({ listRange: [200] }); }); it('should return the correct currency', () => { - const listPrice = 100; + const listPrice = [100]; const userSubsidyApplicableToCourse = null; const { result } = renderHook(() => useCoursePriceForUserSubsidy({ listPrice, @@ -1295,7 +1291,7 @@ describe('useMinimalCourseMetadata', () => { const mockLogoImageUrl = 'https://fake-logo.url'; const mockOrgMarketingUrl = 'https://fake-mktg.url'; const mockWeeksToComplete = 8; - const mockListPrice = 100; + const mockListPrice = [100]; const mockCurrency = 'USD'; const mockCourseTitle = 'Test Course Title'; const mockCourseRunStartDate = '2023-04-20T12:00:00Z'; @@ -1326,7 +1322,7 @@ describe('useMinimalCourseMetadata', () => { }], }; const coursePrice = { - list: mockListPrice, + listRange: mockListPrice, }; const Wrapper = ({ children }) => ( @@ -1389,7 +1385,7 @@ describe('useMinimalCourseMetadata', () => { title: 'Test Course Title', startDate: '2023-04-20T12:00:00Z', duration: '8 Weeks', - priceDetails: { price: 100, currency: 'USD' }, + priceDetails: { price: [100], currency: 'USD' }, }; useCourseMetadata.mockReturnValue(courseMetadataTransformer({ transformed: baseCourseMetadataValue })); const { result } = renderHook(() => useMinimalCourseMetadata(), { wrapper: Wrapper }); @@ -1410,7 +1406,7 @@ describe('useMinimalCourseMetadata', () => { title: 'Test Course Title', startDate: undefined, duration: '-', - priceDetails: { price: 100, currency: 'USD' }, + priceDetails: { price: [100], currency: 'USD' }, }; useCourseMetadata.mockReturnValue(courseMetadataTransformer({ transformed: updatedCourseMetadataValue })); const { result } = renderHook( @@ -1437,7 +1433,7 @@ describe('useMinimalCourseMetadata', () => { title: 'Test Course Title', startDate: '2023-04-20T12:00:00Z', duration: '1 Week', - priceDetails: { price: 100, currency: 'USD' }, + priceDetails: { price: [100], currency: 'USD' }, }; useCourseMetadata.mockReturnValue(courseMetadataTransformer({ transformed: updatedCourseMetadataValue })); const { result } = renderHook( @@ -1467,7 +1463,7 @@ describe('useMinimalCourseMetadata', () => { title: 'Test Course Title', startDate: '2023-04-20T12:00:00Z', duration: '8 Weeks', - priceDetails: { price: 100, currency: 'USD' }, + priceDetails: { price: [100], currency: 'USD' }, }; useCourseMetadata.mockReturnValue(courseMetadataTransformer({ transformed: updatedCourseMetadataValue })); const { result } = renderHook( @@ -1663,6 +1659,7 @@ describe('useCourseListPrice', () => { }, activeCourseRun: { firstEnrollablePaidSeatPrice: 25, + fixedPriceUsd: 35, }, entitlements: [ { @@ -1679,46 +1676,83 @@ describe('useCourseListPrice', () => { beforeEach(() => { jest.clearAllMocks(); useCourseRedemptionEligibility.mockReturnValue({ data: { listPrice: mockListPrice } }); - useCourseMetadata.mockReturnValue(mockListPrice || getCoursePrice(baseCourseMetadataValue)); + // NOTE: `useCourseMetadata`'s mocked return value assumes the returned value + // from the `select` function passed to the hook. + useCourseMetadata.mockReturnValue({ data: mockListPrice || getCoursePrice(baseCourseMetadataValue) }); }); it('should return the list price if one exist', () => { const { result } = renderHook( () => useCourseListPrice(), { wrapper: Wrapper }, ); - expect(result.current).toEqual(mockListPrice); + const expectedListPrice = mockListPrice; + const courseMetadataSelectFn = useCourseMetadata.mock.calls[0][0].select; + expect(expectedListPrice).toEqual(courseMetadataSelectFn({ transformed: baseCourseMetadataValue })); + expect(result.current).toEqual({ data: expectedListPrice }); }); - it('should not return the list price if one doesnt, first fallback, firstEnrollablePaidSeatPrice', () => { + it('should not return the list price if one doesnt exist, fall back to fixed_price_usd from getCoursePrice', () => { const updatedListPrice = undefined; useCourseRedemptionEligibility.mockReturnValue({ data: { listPrice: updatedListPrice } }); - useCourseMetadata.mockReturnValue(updatedListPrice || getCoursePrice(baseCourseMetadataValue)); + useCourseMetadata.mockReturnValue({ data: updatedListPrice || getCoursePrice(baseCourseMetadataValue) }); const { result } = renderHook( () => useCourseListPrice(), { wrapper: Wrapper }, ); - expect(result.current).toEqual(baseCourseMetadataValue.activeCourseRun.firstEnrollablePaidSeatPrice); + const expectedListPrice = [baseCourseMetadataValue.activeCourseRun.fixedPriceUsd]; + const courseMetadataSelectFn = useCourseMetadata.mock.calls[0][0].select; + expect(expectedListPrice).toEqual(courseMetadataSelectFn({ transformed: baseCourseMetadataValue })); + expect(result.current).toEqual({ data: expectedListPrice }); }); - it('should not return the list price if one doesnt, second fallback, entitlements', () => { + it('should not return the list price if one doesnt exist, fall back to firstEnrollablePaidSeatPrice from getCoursePrice', () => { const updatedListPrice = undefined; useCourseRedemptionEligibility.mockReturnValue({ data: { listPrice: updatedListPrice } }); + delete baseCourseMetadataValue.activeCourseRun.fixedPriceUsd; + useCourseMetadata.mockReturnValue({ data: updatedListPrice || getCoursePrice(baseCourseMetadataValue) }); + const { result } = renderHook( + () => useCourseListPrice(), + { wrapper: Wrapper }, + ); + const expectedListPrice = [baseCourseMetadataValue.activeCourseRun.firstEnrollablePaidSeatPrice]; + const courseMetadataSelectFn = useCourseMetadata.mock.calls[0][0].select; + expect(expectedListPrice).toEqual(courseMetadataSelectFn({ transformed: baseCourseMetadataValue })); + expect(result.current).toEqual({ data: expectedListPrice }); + }); + it('should not return the list price if one doesnt exit, fall back to entitlements from getCoursePrice', () => { + const updatedListPrice = undefined; + useCourseRedemptionEligibility.mockReturnValue({ data: { listPrice: updatedListPrice } }); + delete baseCourseMetadataValue.activeCourseRun.fixedPriceUsd; delete baseCourseMetadataValue.activeCourseRun.firstEnrollablePaidSeatPrice; - useCourseMetadata.mockReturnValue(updatedListPrice || getCoursePrice(baseCourseMetadataValue)); + useCourseMetadata.mockReturnValue({ data: updatedListPrice || getCoursePrice(baseCourseMetadataValue) }); const { result } = renderHook( () => useCourseListPrice(), { wrapper: Wrapper }, ); - expect(result.current).toEqual(baseCourseMetadataValue.entitlements[0].price); + const expectedListPrice = [baseCourseMetadataValue.entitlements[0].price]; + const courseMetadataSelectFn = useCourseMetadata.mock.calls[0][0].select; + expect(expectedListPrice).toEqual(courseMetadataSelectFn({ transformed: baseCourseMetadataValue })); + expect(result.current).toEqual({ data: expectedListPrice }); }); it('should not return the list price if one doesnt exist or the course metadata doesnt include it', () => { const updatedListPrice = undefined; useCourseRedemptionEligibility.mockReturnValue({ data: { listPrice: updatedListPrice } }); - delete baseCourseMetadataValue.entitlements; - useCourseMetadata.mockReturnValue(updatedListPrice || getCoursePrice(baseCourseMetadataValue)); + const updatedCourseMetadata = { + ...baseCourseMetadataValue, + activeCourseRun: { + ...baseCourseMetadataValue.activeCourseRun, + fixedPriceUsd: null, + firstEnrollablePaidSeatPrice: null, + }, + entitlements: [], + }; + useCourseMetadata.mockReturnValue({ data: updatedListPrice || getCoursePrice(updatedCourseMetadata) }); const { result } = renderHook( () => useCourseListPrice(), { wrapper: Wrapper }, ); - expect(result.current).toEqual(undefined); + const expectedListPrice = null; + const courseMetadataSelectFn = useCourseMetadata.mock.calls[0][0].select; + expect(expectedListPrice).toEqual(courseMetadataSelectFn({ transformed: updatedCourseMetadata })); + expect(result.current).toEqual({ data: expectedListPrice }); }); }); diff --git a/src/components/course/data/tests/utils.test.jsx b/src/components/course/data/tests/utils.test.jsx index f9f056407b..3d3902a3c7 100644 --- a/src/components/course/data/tests/utils.test.jsx +++ b/src/components/course/data/tests/utils.test.jsx @@ -4,9 +4,7 @@ import MockDate from 'mockdate'; import '@testing-library/jest-dom/extend-expect'; import { getConfig } from '@edx/frontend-platform'; -import { - DISABLED_ENROLL_REASON_TYPES, -} from '../constants'; +import { DISABLED_ENROLL_REASON_TYPES } from '../constants'; import { findCouponCodeForCourse, findEnterpriseOfferForCourse, @@ -919,7 +917,7 @@ describe('transformedCourseMetadata', () => { const mockLogoImageUrl = 'https://fake-logo.url'; const mockOrgMarketingUrl = 'https://fake-mktg.url'; const mockWeeksToComplete = 8; - const mockListPrice = 100; + const mockListPrice = [100]; const mockCurrency = 'USD'; const mockCourseTitle = 'Test Course Title'; const mockCourseRunStartDate = '2023-04-20T12:00:00Z'; @@ -958,7 +956,7 @@ describe('transformedCourseMetadata', () => { }, }; const coursePrice = { - list: mockListPrice, + listRange: mockListPrice, }; const expectedValue = { duration: '8 Weeks', @@ -969,7 +967,7 @@ describe('transformedCourseMetadata', () => { }, priceDetails: { currency: 'USD', - price: 100, + price: [100], }, startDate: '2023-04-20T12:00:00Z', title: 'Test Course Title', diff --git a/src/components/course/data/utils.jsx b/src/components/course/data/utils.jsx index 6093307cfe..4a3328cb66 100644 --- a/src/components/course/data/utils.jsx +++ b/src/components/course/data/utils.jsx @@ -12,6 +12,7 @@ import { DISABLED_ENROLL_USER_MESSAGES, ENROLLMENT_COURSE_RUN_KEY_QUERY_PARAM, ENROLLMENT_FAILED_QUERY_PARAM, + ZERO_PRICE, } from './constants'; import MicroMastersSvgIcon from '../../../assets/icons/micromasters.svg'; import ProfessionalSvgIcon from '../../../assets/icons/professional.svg'; @@ -20,7 +21,7 @@ import XSeriesSvgIcon from '../../../assets/icons/xseries.svg'; import CreditSvgIcon from '../../../assets/icons/credit.svg'; import { PROGRAM_TYPE_MAP } from '../../program/data/constants'; import { programIsMicroMasters, programIsProfessionalCertificate } from '../../program/data/utils'; -import { hasValidStartExpirationDates } from '../../../utils/common'; +import { formatPrice, hasValidStartExpirationDates, isDefinedAndNotNull } from '../../../utils/common'; import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; import { findHighestLevelEntitlementSku, @@ -152,7 +153,22 @@ export function getProgramIcon(type) { } } -export const numberWithPrecision = (number, precision = 2) => number.toFixed(precision); +/** + * Displays content price with precision as a range or singular price + * @param priceRange + * @returns {*|string} + */ +export const getContentPriceDisplay = (priceRange) => { + if (!priceRange?.length) { + return formatPrice(ZERO_PRICE); + } + const minPrice = Math.min(...priceRange); + const maxPrice = Math.max(...priceRange); + if (maxPrice !== minPrice) { + return `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`; + } + return formatPrice(priceRange[0]); +}; /** * @@ -803,7 +819,7 @@ export function getLinkToCourse(course, slug) { */ export function getEntitlementPrice(entitlements) { if (entitlements?.length) { - return Number(entitlements[0].price); + return parseFloat(entitlements[0].price); } return undefined; } @@ -817,10 +833,16 @@ export function getEntitlementPrice(entitlements) { * @returns Price for the course run. */ export function getCoursePrice(course) { - if (course.activeCourseRun?.firstEnrollablePaidSeatPrice) { - return course.activeCourseRun.firstEnrollablePaidSeatPrice; + if (isDefinedAndNotNull(course.activeCourseRun?.fixedPriceUsd)) { + return [parseFloat(course.activeCourseRun.fixedPriceUsd)]; + } + if (isDefinedAndNotNull(course.activeCourseRun?.firstEnrollablePaidSeatPrice)) { + return [course.activeCourseRun.firstEnrollablePaidSeatPrice]; } - return getEntitlementPrice(course.entitlements); + if (course.entitlements.length > 0) { + return [getEntitlementPrice(course.entitlements)]; + } + return null; } /** @@ -904,7 +926,7 @@ export function transformedCourseMetadata({ startDate: getCourseStartDate({ courseRun }), duration: getDuration(), priceDetails: { - price: coursePrice.list, + price: coursePrice.listRange, currency, }, }; diff --git a/src/components/course/routes/tests/ExternalCourseEnrollment.test.jsx b/src/components/course/routes/tests/ExternalCourseEnrollment.test.jsx index 451f8b9786..3ac048df36 100644 --- a/src/components/course/routes/tests/ExternalCourseEnrollment.test.jsx +++ b/src/components/course/routes/tests/ExternalCourseEnrollment.test.jsx @@ -5,17 +5,15 @@ import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { renderWithRouterProvider } from '../../../../utils/tests'; -import { - DISABLED_ENROLL_REASON_TYPES, -} from '../../data/constants'; +import { DISABLED_ENROLL_REASON_TYPES } from '../../data/constants'; import ExternalCourseEnrollment from '../ExternalCourseEnrollment'; import { CourseContext } from '../../CourseContextProvider'; import { + LEARNER_CREDIT_SUBSIDY_TYPE, + LICENSE_SUBSIDY_TYPE, useCourseRedemptionEligibility, useEnterpriseCourseEnrollments, useEnterpriseCustomer, - LEARNER_CREDIT_SUBSIDY_TYPE, - LICENSE_SUBSIDY_TYPE, } from '../../../app/data'; import { enterpriseCustomerFactory } from '../../../app/data/services/data/__factories__'; import { @@ -108,7 +106,7 @@ describe('ExternalCourseEnrollment', () => { startDate: '2023-03-05', duration: '3 Weeks', priceDetails: { - price: 100, + price: [100], currency: 'USD', }, }, diff --git a/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx b/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx index 9eabb8b626..cec9fdc1c5 100644 --- a/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx +++ b/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx @@ -54,7 +54,7 @@ describe('ExternalCourseEnrollmentConfirmation', () => { logoImgUrl: 'https://example.com/logo.png', }, priceDetails: { - price: 100, + price: [100], currency: 'USD', }, startDate: '2023-03-05T12:00:00Z', diff --git a/src/components/course/tests/CourseSidebarPrice.test.jsx b/src/components/course/tests/CourseSidebarPrice.test.jsx index 0c637273b7..b99bc77573 100644 --- a/src/components/course/tests/CourseSidebarPrice.test.jsx +++ b/src/components/course/tests/CourseSidebarPrice.test.jsx @@ -1,17 +1,15 @@ import React from 'react'; -import { screen, render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import CourseSidebarPrice from '../CourseSidebarPrice'; +import { SUBSIDY_DISCOUNT_TYPE_MAP } from '../data/constants'; import { - SUBSIDY_DISCOUNT_TYPE_MAP, -} from '../data/constants'; -import { - useEnterpriseCustomer, + COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE, - COUPON_CODE_SUBSIDY_TYPE, + useEnterpriseCustomer, } from '../../app/data'; import { enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; import { @@ -64,7 +62,7 @@ describe(' ', () => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: null }); - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 7.5 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [7.5] }, currency: 'USD' }); useIsCourseAssigned.mockReturnValue({ isCourseAssigned: false }); useCanUserRequestSubsidyForCourse.mockReturnValue(false); }); @@ -95,7 +93,7 @@ describe(' ', () => { subsidyType: ENTERPRISE_OFFER_SUBSIDY_TYPE, }; useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: mockEnterpriseOfferSubsidy }); - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); render(); expect(screen.getByText('Priced reduced from:')).toBeInTheDocument(); expect(screen.getByText(/\$7.50 USD/)).toBeInTheDocument(); @@ -126,7 +124,7 @@ describe(' ', () => { }); test('subscription license subsidy, shows no price, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: { subsidyType: LICENSE_SUBSIDY_TYPE }, }); @@ -140,7 +138,7 @@ describe(' ', () => { }); test('coupon code 100% subsidy, shows no price, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: FULL_COUPON_CODE_SUBSIDY, }); @@ -153,8 +151,8 @@ describe(' ', () => { expect(screen.queryByText('This course is assigned to you. The price of this course is already covered by your organization.')).not.toBeInTheDocument(); }); - test('coupon code non-full subsidy, shows discounted price only, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 3.75 }, currency: 'USD' }); + test('coupon code non-full subsidy, shows discountedList price only, correct message', () => { + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [3.75] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: PARTIAL_COUPON_CODE_SUBSIDY, }); @@ -168,7 +166,7 @@ describe(' ', () => { test('assigned course, shows no price, correct message', () => { useIsCourseAssigned.mockReturnValue({ isCourseAssigned: true }); - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: { subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE }, }); @@ -192,7 +190,7 @@ describe(' ', () => { expect(screen.queryByText('This course is assigned to you. The price of this course is already covered by your organization.')).not.toBeInTheDocument(); }); test('subscription license subsidy, shows orig crossed out price, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: { subsidyType: LICENSE_SUBSIDY_TYPE }, }); @@ -204,7 +202,7 @@ describe(' ', () => { expect(screen.queryByText('This course is assigned to you. The price of this course is already covered by your organization.')).not.toBeInTheDocument(); }); test('coupon code 100% subsidy, shows orig price, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: FULL_COUPON_CODE_SUBSIDY, }); @@ -215,8 +213,8 @@ describe(' ', () => { expect(screen.queryByText("This course can be purchased with your organization's learner credit")).not.toBeInTheDocument(); expect(screen.queryByText('This course is assigned to you. The price of this course is already covered by your organization.')).not.toBeInTheDocument(); }); - test('coupon code non-full subsidy, shows orig and discounted price only, correct message', () => { - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 3.75 }, currency: 'USD' }); + test('coupon code non-full subsidy, shows orig and discountedList price only, correct message', () => { + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [3.75] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: PARTIAL_COUPON_CODE_SUBSIDY, }); @@ -230,7 +228,7 @@ describe(' ', () => { }); test('assigned course, shows orig price, correct message', () => { useIsCourseAssigned.mockReturnValue({ isCourseAssigned: true }); - useCoursePrice.mockReturnValue({ coursePrice: { list: 7.5, discounted: 0 }, currency: 'USD' }); + useCoursePrice.mockReturnValue({ coursePrice: { listRange: [7.5], discountedList: [0] }, currency: 'USD' }); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: { subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE }, }); diff --git a/src/components/dashboard/SubscriptionExpirationModal.jsx b/src/components/dashboard/SubscriptionExpirationModal.jsx index 4977f1bafc..4e627f1767 100644 --- a/src/components/dashboard/SubscriptionExpirationModal.jsx +++ b/src/components/dashboard/SubscriptionExpirationModal.jsx @@ -25,10 +25,13 @@ const SubscriptionExpirationModal = () => { } = useContext(AppContext); const intl = useIntl(); - const [isOpen, , close] = useToggle(true); - const { data: enterpriseCustomer } = useEnterpriseCustomer(); const { data: subscriptions } = useSubscriptions(); const { subscriptionPlan, subscriptionLicense } = subscriptions; + const seenExpiredSubscriptionModal = !!global.localStorage.getItem( + EXPIRED_SUBSCRIPTION_MODAL_LOCALSTORAGE_KEY(subscriptionLicense), + ); + const [isOpen, , close] = useToggle(!seenExpiredSubscriptionModal); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); const { daysUntilExpirationIncludingRenewals, expirationDate, @@ -88,10 +91,6 @@ const SubscriptionExpirationModal = () => { close(); global.localStorage.setItem(EXPIRED_SUBSCRIPTION_MODAL_LOCALSTORAGE_KEY(subscriptionLicense), 'true'); }; - - const seenExpiredSubscriptionModal = !!global.localStorage.getItem( - EXPIRED_SUBSCRIPTION_MODAL_LOCALSTORAGE_KEY(subscriptionLicense), - ); // If the subscription has already expired, we show a different un-dismissible modal if (!isCurrent) { if (seenExpiredSubscriptionModal) { @@ -109,6 +108,7 @@ const SubscriptionExpirationModal = () => { )} hasCloseButton + onClose={handleSubscriptionExpiredModalDismissal} >

Your organization's access to your subscription has expired. You will only have audit @@ -170,6 +170,7 @@ const SubscriptionExpirationModal = () => { )} hasCloseButton + onClose={handleSubscriptionExpiringModalDismissal} >

Your organization's access to your current subscription is expiring in diff --git a/src/components/executive-education-2u/components/CourseSummaryCard.jsx b/src/components/executive-education-2u/components/CourseSummaryCard.jsx index 075b864a88..d2aa4d96c5 100644 --- a/src/components/executive-education-2u/components/CourseSummaryCard.jsx +++ b/src/components/executive-education-2u/components/CourseSummaryCard.jsx @@ -1,28 +1,26 @@ import PropTypes from 'prop-types'; import dayjs from 'dayjs'; import { - Card, Image, Row, Col, Hyperlink, + Card, Col, Hyperlink, Image, Row, } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { - useMinimalCourseMetadata, - DATE_FORMAT, - ZERO_PRICE, - numberWithPrecision, + DATE_FORMAT, getContentPriceDisplay, useMinimalCourseMetadata, ZERO_PRICE, } from '../../course/data'; +import { formatPrice } from '../../../utils/common'; const CourseSummaryCard = ({ enrollmentCompleted }) => { const { data: minimalCourseMetadata } = useMinimalCourseMetadata(); let coursePrice = null; - const precisePrice = minimalCourseMetadata.priceDetails?.price ? `$${numberWithPrecision( + const precisePrice = minimalCourseMetadata.priceDetails?.price ? `${getContentPriceDisplay( minimalCourseMetadata.priceDetails.price, )} ${minimalCourseMetadata.priceDetails.currency}` : '-'; if (enrollmentCompleted && minimalCourseMetadata.priceDetails?.price) { coursePrice = ( <>{precisePrice} - ${numberWithPrecision(ZERO_PRICE)} {minimalCourseMetadata.priceDetails.currency} + {formatPrice(ZERO_PRICE)} {minimalCourseMetadata.priceDetails.currency} ); } else { diff --git a/src/components/executive-education-2u/components/RegistrationSummaryCard.jsx b/src/components/executive-education-2u/components/RegistrationSummaryCard.jsx index 437dc6b7d5..077ff43373 100644 --- a/src/components/executive-education-2u/components/RegistrationSummaryCard.jsx +++ b/src/components/executive-education-2u/components/RegistrationSummaryCard.jsx @@ -1,11 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { - Card, Row, Col, -} from '@openedx/paragon'; +import { Card, Col, Row } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { numberWithPrecision } from '../../course/data/utils'; -import { CURRENCY_USD } from '../../course/data/constants'; +import { getContentPriceDisplay } from '../../course/data/utils'; +import { CURRENCY_USD, ZERO_PRICE } from '../../course/data/constants'; +import { formatPrice } from '../../../utils/common'; const RegistrationSummaryCard = ({ priceDetails }) => ( (

- {priceDetails?.price ? `$${numberWithPrecision(priceDetails.price)} ${priceDetails.currency}` : '-'} + {priceDetails?.price ? `${getContentPriceDisplay(priceDetails.price)} ${priceDetails.currency}` : '-'}
- {priceDetails?.price ? `$${numberWithPrecision(0)} ${priceDetails?.currency ? priceDetails.currency : CURRENCY_USD}` : '-'} + {priceDetails?.price ? `${formatPrice(ZERO_PRICE)} ${priceDetails?.currency ? priceDetails.currency : CURRENCY_USD}` : '-'}
{ const { data: enterpriseCustomer } = useEnterpriseCustomer(); const { data: videoData } = useVideoDetails(); const { data: courseMetadata } = useVideoCourseMetadata(videoData?.courseKey); + const coursePrice = getCoursePrice(courseMetadata); const [pacingType, pacingTypeContent] = useCoursePacingType(courseMetadata?.activeCourseRun); const { data: { subscriptionLicense } } = useSubscriptions(); const playerRef = React.useRef(null); @@ -205,7 +201,7 @@ const VideoDetailPage = () => { description="Label for the original price of the course." /> - ${getCoursePrice(courseMetadata)} USD + {getContentPriceDisplay(coursePrice)} USD

  • hello i am descritpion
  • ', + outcome: '
    • hello i am description
    ', title: 'Test Course Title', activeCourseRun: mockCourseRun, courseRuns: [mockCourseRun], + entitlements: [], }; const mockCourseReviews = { course_key: 'course-test-key', @@ -117,7 +122,7 @@ const VideoDetailPageWrapper = ({ ); -describe('VideoDetailPage Tests', () => { +describe('VideoDetailPage', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); @@ -180,6 +185,12 @@ describe('VideoDetailPage Tests', () => { expect(screen.getByText('Unknown')).toBeInTheDocument(); }); + it('renders the price', () => { + useVideoCourseMetadata.mockReturnValue({ data: { ...mockCourseMetadata, activeCourseRun: { ...mockCourseRun, levelType: 'Unknown' } } }); + renderWithRouter(); + expect(screen.getByText(`${formatPrice(mockCourseRun.firstEnrollablePaidSeatPrice)} USD`)).toBeInTheDocument(); + }); + it('renders a not found page when video data is not found', () => { useVideoDetails.mockReturnValue({ data: null }); renderWithRouter(); diff --git a/src/utils/common.js b/src/utils/common.js index 84774b0b28..9482690fe8 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -39,6 +39,10 @@ export const hasTruthyValue = (value) => { return values.every(item => !!item); }; +export const sumOfArray = (values) => (values.every(item => typeof item === 'number' && !Number.isNaN(item)) + ? values.reduce((prev, next) => prev + next, 0) + : null); + export const hasValidStartExpirationDates = ({ startDate, expirationDate, endDate }) => { const now = dayjs(); // Subscriptions use "expirationDate" while Codes use "endDate" @@ -178,3 +182,14 @@ export function i18nFormatTimestamp({ intl, timestamp, formatOpts = {} }) { ...formatOpts, }); } + +export const formatPrice = (price, options = {}) => { + const USDollar = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + ...options, + }); + return USDollar.format(Math.abs(price)); +};