Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontends/api/src/test-utils/factories/learningResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ const learningResourceCourseNumber: Factory<CourseNumber> = (
const _learningResourceShared = (): Partial<
Omit<LearningResource, "resource_type">
> => {
const free = Math.random() < 0.5
return {
id: uniqueEnforcerId.enforce(() => faker.number.int()),
professional: faker.datatype.boolean(),
Expand All @@ -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: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,26 @@ export default meta

type Story = StoryObj<typeof LearningResourceListCard>

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"],
}),
},
}
Expand All @@ -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,
}),
},
}

Expand All @@ -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,
}),
},
}
Expand All @@ -119,6 +141,7 @@ export const VideoPlaylist: Story = {
args: {
resource: makeResource({
resource_type: ResourceTypeEnum.VideoPlaylist,
free: true,
}),
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<span>{getReadableResourceType(resource.resource_type)}</span>
{resource.certification && (
<Certificate>
<RiAwardFill />
Certificate
Certificate{prices?.certificate ? ":" : ""}{" "}
{getDisplayPrice(prices?.certificate)}
</Certificate>
)}
{price && <Price>{price}</Price>}
<Price>{getDisplayPrice(prices?.course)}</Price>
</>
)
}
Expand Down