diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index fb45aaf3ee..26376b54c6 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -224,6 +224,7 @@ const learningResourceCourseNumber: Factory = ( const _learningResourceShared = (): Partial< Omit > => { + const free = Math.random() < 0.5 return { id: uniqueEnforcerId.enforce(() => faker.number.int()), professional: faker.datatype.boolean(), @@ -233,7 +234,8 @@ const _learningResourceShared = (): Partial< image: learningResourceImage(), offered_by: maybe(learningResourceOfferor) ?? null, platform: maybe(learningResourcePlatform) ?? null, - prices: ["0.00"], + free, + prices: free ? ["0"] : [faker.finance.amount({ min: 0, max: 100 })], readable_id: faker.lorem.slug(), course_feature: repeat(faker.lorem.word), runs: [], diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx index 716fce9c09..ce142c091f 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx @@ -73,11 +73,26 @@ export default meta type Story = StoryObj -export const Course: Story = { +export const PaidCourse: Story = { args: { resource: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], + free: false, + certification: true, + prices: ["999"], + }), + }, +} + +export const FreeCourse: Story = { + args: { + resource: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [factories.learningResources.run()], + free: true, + certification: true, + prices: ["0", "400"], }), }, } @@ -96,13 +111,19 @@ export const Program: Story = { export const Podcast: Story = { args: { - resource: makeResource({ resource_type: ResourceTypeEnum.Podcast }), + resource: makeResource({ + resource_type: ResourceTypeEnum.Podcast, + free: true, + }), }, } export const PodcastEpisode: Story = { args: { - resource: makeResource({ resource_type: ResourceTypeEnum.PodcastEpisode }), + resource: makeResource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + free: true, + }), }, } @@ -111,6 +132,7 @@ export const Video: Story = { resource: makeResource({ resource_type: ResourceTypeEnum.Video, url: "https://www.youtube.com/watch?v=4A9bGL-_ilA", + free: true, }), }, } @@ -119,6 +141,7 @@ export const VideoPlaylist: Story = { args: { resource: makeResource({ resource_type: ResourceTypeEnum.VideoPlaylist, + free: true, }), }, } diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index 9e9171c0ea..3ccac0b182 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -169,4 +169,78 @@ describe("Learning Resource List Card", () => { expect(matching.length).toBe(1) expect(matching[0]).toHaveAttribute("alt", expected.alt) }) + + describe("Price display", () => { + test('Free course without certificate option displays "Free"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: true, + prices: ["0"], + }) + setup(resource) + screen.getByText("Free") + }) + + test('Free course with paid certificate option displays the certificate price and "Free"', () => { + const resource = factories.learningResources.resource({ + certification: true, + free: true, + prices: ["0", "49"], + }) + setup(resource) + screen.getByText("Certificate: $49") + screen.getByText("Free") + }) + + test('Free course with paid certificate option range displays the certificate price range and "Free". Prices are sorted correctly', () => { + const resource = factories.learningResources.resource({ + certification: true, + free: true, + prices: ["0", "99", "49"], + }) + setup(resource) + screen.getByText("Certificate: $49 - $99") + screen.getByText("Free") + }) + + test("Paid course without certificate option displays the course price", () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["49"], + }) + setup(resource) + screen.getByText("$49") + }) + + test("Amount with currency subunits are displayed to 2 decimal places", () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["49.50"], + }) + setup(resource) + screen.getByText("$49.50") + }) + + test('Free course with empty prices array displays "Free"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: true, + prices: [], + }) + setup(resource) + screen.getByText("Free") + }) + + test('Paid course that has zero price (prices not ingested) displays "Paid"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["0"], + }) + setup(resource) + screen.getByText("Paid") + }) + }) }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index d476927895..bafa231907 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -94,29 +94,142 @@ const getEmbedlyUrl = (url: string, isMobile: boolean) => { }) } -const getPrice = (resource: LearningResource) => { +type Prices = { + course: null | number + certificate: null | number +} + +const getPrices = (resource: LearningResource) => { + const prices: Prices = { + course: null, + certificate: null, + } + if (!resource) { + return prices + } + + const resourcePrices = resource.prices + .map((price) => Number(price)) + .sort((a, b) => a - b) + + if (resourcePrices.length > 1) { + /* The resource is free and offers a paid certificate option, e.g. + * { prices: [0, 49], free: true, certification: true } + */ + if (resource.certification && resource.free) { + const certificatedPrices = resourcePrices.filter((price) => price > 0) + return { + course: 0, + certificate: + certificatedPrices.length === 1 + ? certificatedPrices[0] + : [ + certificatedPrices[0], + certificatedPrices[certificatedPrices.length - 1], + ], + } + } + + /* The resource is not free and has a range of prices, e.g. + * { prices: [950, 999], free: false, certification: true|false } + */ + if (resource.certification && !resource.free && Number(resourcePrices[0])) { + return { + course: [resourcePrices[0], resourcePrices[resourcePrices.length - 1]], + certificate: null, + } + } + + /* The resource is not free but has a zero price option (prices not ingested correctly) + * { prices: [0, 999], free: false, certification: true|false } + */ + if (!resource.free && !Number(resourcePrices[0])) { + return { + course: +Infinity, + certificate: null, + } + } + + /* We are not expecting multiple prices for courses with no certificate option. + * For resourses always certificated, there is one price that includes the certificate. + */ + } else if (resourcePrices.length === 1) { + if (!Number(resourcePrices[0])) { + /* Sometimes price info is missing, but the free flag is reliable. + */ + if (!resource.free) { + return { + course: +Infinity, + certificate: null, + } + } + + return { + course: 0, + certificate: null, + } + } else { + /* If the course has no free option, the price of the certificate + * is included in the price of the course. + */ + return { + course: Number(resourcePrices[0]), + certificate: null, + } + } + } else if (resourcePrices.length === 0) { + return { + course: resource.free ? 0 : +Infinity, + certificate: null, + } + } + + return prices +} + +const getDisplayPrecision = (price: number) => { + if (Number.isInteger(price)) { + return price.toFixed(0) + } + return price.toFixed(2) +} + +const getDisplayPrice = (price: number | number[] | null) => { + if (price === null) { return null } - const price = resource.prices?.[0] - if (resource.free) { + if (price === 0) { return "Free" } - return price ? `$${price}` : null + if (price === +Infinity) { + return "Paid" + } + if (Array.isArray(price)) { + return `$${getDisplayPrecision(price[0])} - $${getDisplayPrecision(price[1])}` + } + return `$${getDisplayPrecision(price)}` } +/* This displays a single price for courses with no free option + * (price includes the certificate). For free courses with the + * option of a paid certificate, the certificate price displayed + * in the certificate badge alongside the course "Free" price. + */ const Info = ({ resource }: { resource: LearningResource }) => { - const price = getPrice(resource) + const prices = getPrices(resource) + getDisplayPrice(+Infinity) return ( <> {getReadableResourceType(resource.resource_type)} {resource.certification && ( - Certificate + Certificate{prices?.certificate ? ":" : ""}{" "} + {getDisplayPrice(prices?.certificate)} )} - {price && {price}} + {getDisplayPrice(prices?.course)} ) }