Skip to content

Commit 77023b2

Browse files
Product pages, Purchasable Programs + enrollment_modes (#2995)
* bump mitxonline client * fix tests and ts errors * use new cart apis * bump client * bump client version * update summary price display * show financial aid pricing in course summary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * update enrollment buttons for paid enrollment and financial aid Show "Enroll Now—$X" for paid-only offerings. When financial aid is applied to a course, display the discounted price with the original price stricken. Disable the button when no price is available. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * update enrollment dialogs and buttons for enrollment mode support - ProgramEnrollmentDialog: remove course picker, enroll in program directly; add certificate upsell (with Add to Cart) for "both" enrollment modes - CourseEnrollmentDialog: add hideUpsell prop for free-only courses - ProgramEnrollmentButton: branch on enrollmentType — free enrolls directly, paid goes straight to cart, both opens dialog - CourseEnrollmentButton: branch on enrollmentType — free opens dialog with upsell hidden, paid goes straight to cart, both opens full dialog - Add useCreateProgramEnrollment hook to mitxonline enrollment hooks - Update tests for all new behaviors; remove 100x repeat from ProgramEnrollmentButton.test.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * hide enrollment upsell based on run/program enrollment modes CourseEnrollmentDialog now derives upsell visibility from the selected run's enrollment_modes rather than a hideUpsell prop, so the upsell reacts dynamically as the user changes their run selection. Removes the hideUpsell prop entirely. ProgramEnrollmentDialog now checks program.enrollment_modes and only renders the certificate upsell when the program offers both free and paid options. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * make flakey test detection easier * unify price formatting: drop .00 for whole-dollar amounts Add formatPrice() utility that uses minimumFractionDigits:0 so whole dollar amounts render as "$900" rather than "$900.00". Use it in program components (ProgramEnrollmentButton, ProgramPriceRow, ProgramCertificateBox) which previously used raw template literals. Update all affected tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add click-action coverage for free/both enrollment modes Add test.each covering free-only and both-modes cases that clicking 'Enroll for Free' as an authenticated user opens CourseEnrollmentDialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: paid enrollment loading state, error handling, and price formatting - Show loading spinner (endIcon) while basket API calls are in flight, keeping button text visible and disabling the button - Catch errors from clearBasket/addToBasket mutateAsync and surface them via an Alert below the button; same for free program enrollment - Fix inconsistent price formatting in ProgramCertificateUpsell: use formatPrice() instead of template literal to drop unnecessary .00 - Collapse redundant free/both branches in CourseEnrollmentButton click handler - Add tests for loading and error states in both enrollment buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * unify clear+add basket logic * make onReset optional * fix copy * add a test covering successful enrollment redirection * handle errors in dialogs * no redundant resets * simplify: use mutate+onSuccess chaining in useReplaceBasketItem; remove redundant reset calls and try/catch at call sites Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * reset during combined mutation, remove a comment * remove unncessary comparison * fix a rebasing issue * disable enrollment button when enrollment_modes is empty Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * show cents except on button * open enrollment dialog for multi-run or upsell; direct enroll otherwise - Open dialog when enrollmentType is "both" (upsell) or multiple enrollable runs exist - For paid-only + single run, go directly to checkout (unchanged) - For free-only + single run, enroll directly and redirect to dashboard home Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * be defensive in dialog: hide/disable upsell+free button for bad enrollment data - confirmText now requires enrollmentType to be "free" or "both" (not just not-paid), so it's hidden for "none" (bad data) as well as "paid" - isFreeOnly now true for any type that isn't "paid" or "both", so the upsell is disabled for "none" in addition to "free" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * update mitxonline-api-axios to semver release * remove isFreeOnly from upsell props --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 187d942 commit 77023b2

27 files changed

Lines changed: 1758 additions & 1007 deletions

frontends/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"ol-test-utilities": "0.0.0"
3030
},
3131
"dependencies": {
32-
"@mitodl/mitxonline-api-axios": "^2026.2.19",
32+
"@mitodl/mitxonline-api-axios": "^2026.3.3",
3333
"@tanstack/react-query": "^5.66.0",
3434
"axios": "^1.12.2",
3535
"tiny-invariant": "^1.3.3"

frontends/api/src/mitxonline/hooks/baskets/index.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const useAddToBasket = () => {
2323
queryKey: basketQueries.basketState().queryKey,
2424
})
2525

26-
// Redirect to MITx Online cart page
2726
const cartUrl = new URL(
2827
"/cart/",
2928
process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL,
@@ -50,4 +49,40 @@ const useClearBasket = () => {
5049
})
5150
}
5251

53-
export { basketQueries, useAddToBasket, useClearBasket }
52+
/**
53+
* Hook to replace the basket with a single product, then redirect to cart.
54+
*
55+
* This clears the basket before adding the new item because our cart UI does
56+
* not currently allow users to remove items. Having more than one item in the
57+
* basket puts users in a bad UI state. Once the cart supports item removal,
58+
* this hook should be replaced with a direct call to `useAddToBasket`.
59+
*/
60+
const useReplaceBasketItem = () => {
61+
const addToBasket = useAddToBasket()
62+
const clearBasket = useClearBasket()
63+
64+
const mutate = (productId: number) => {
65+
// Reset addToBasket so stale error state from a previous attempt is cleared
66+
// immediately. (clearBasket resets itself when .mutate() is called, but
67+
// addToBasket won't reset until its own .mutate() fires in onSuccess.)
68+
addToBasket.reset()
69+
clearBasket.mutate(undefined, {
70+
onSuccess: () => addToBasket.mutate(productId),
71+
})
72+
}
73+
74+
const mutateAsync = async (productId: number) => {
75+
addToBasket.reset()
76+
await clearBasket.mutateAsync()
77+
await addToBasket.mutateAsync(productId)
78+
}
79+
80+
return {
81+
mutate,
82+
mutateAsync,
83+
isPending: clearBasket.isPending || addToBasket.isPending,
84+
isError: clearBasket.isError || addToBasket.isError,
85+
}
86+
}
87+
88+
export { basketQueries, useAddToBasket, useClearBasket, useReplaceBasketItem }

frontends/api/src/mitxonline/hooks/enrollment/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { enrollmentQueries, enrollmentKeys } from "./queries"
22
import { useMutation, useQueryClient } from "@tanstack/react-query"
3-
import { b2bApi, courseRunEnrollmentsApi } from "../../clients"
3+
import {
4+
b2bApi,
5+
courseRunEnrollmentsApi,
6+
programEnrollmentsApi,
7+
} from "../../clients"
48
import {
59
B2bApiB2bEnrollCreateRequest,
610
EnrollmentsApiEnrollmentsPartialUpdateRequest,
711
CourseRunEnrollmentRequest,
12+
ProgramEnrollmentsApiV3ProgramEnrollmentsCreateRequest,
813
} from "@mitodl/mitxonline-api-axios/v2"
914

1015
const useCreateB2bEnrollment = () => {
@@ -65,11 +70,26 @@ const useDestroyEnrollment = () => {
6570
})
6671
}
6772

73+
const useCreateProgramEnrollment = () => {
74+
const queryClient = useQueryClient()
75+
return useMutation({
76+
mutationFn: (
77+
opts: ProgramEnrollmentsApiV3ProgramEnrollmentsCreateRequest,
78+
) => programEnrollmentsApi.v3ProgramEnrollmentsCreate(opts),
79+
onSettled: () => {
80+
queryClient.invalidateQueries({
81+
queryKey: enrollmentKeys.programEnrollmentsList(),
82+
})
83+
},
84+
})
85+
}
86+
6887
export {
6988
enrollmentQueries,
7089
enrollmentKeys,
7190
useCreateB2bEnrollment,
7291
useCreateEnrollment,
7392
useUpdateEnrollment,
7493
useDestroyEnrollment,
94+
useCreateProgramEnrollment,
7595
}

frontends/api/src/mitxonline/hooks/programs/queries.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { queryOptions } from "@tanstack/react-query"
22
import type {
33
PaginatedV2ProgramCollectionList,
4-
PaginatedV2ProgramList,
4+
PaginatedV2ProgramDetailList,
55
ProgramCollectionsApiProgramCollectionsListRequest,
66
ProgramsApiProgramsListV2Request,
77
ProgramsApiProgramsRetrieveV2Request,
@@ -37,7 +37,7 @@ const programsQueries = {
3737
programsList: (opts: ProgramsApiProgramsListV2Request) =>
3838
queryOptions({
3939
queryKey: programsKeys.programsList(opts),
40-
queryFn: async (): Promise<PaginatedV2ProgramList> => {
40+
queryFn: async (): Promise<PaginatedV2ProgramDetailList> => {
4141
return programsApi.programsListV2(opts).then((res) => res.data)
4242
},
4343
}),

frontends/api/src/mitxonline/test-utils/factories/courses.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
CourseRunV2,
66
V1CourseWithCourseRuns,
77
ProductFlexibilePrice,
8+
EnrollmentMode,
89
} from "@mitodl/mitxonline-api-axios/v2"
910
import { faker } from "@faker-js/faker/locale/en"
1011
import { UniqueEnforcer } from "enforce-unique"
@@ -13,6 +14,15 @@ import { has } from "lodash"
1314
const uniqueCourseId = new UniqueEnforcer()
1415
const uniqueCourseRunId = new UniqueEnforcer()
1516

17+
const enrollmentMode: Factory<EnrollmentMode> = (overrides = {}) => {
18+
return {
19+
mode_slug: faker.lorem.slug(),
20+
mode_display_name: faker.lorem.words(2),
21+
requires_payment: faker.datatype.boolean(),
22+
...overrides,
23+
}
24+
}
25+
1626
const v1Course: PartialFactory<V1CourseWithCourseRuns> = (overrides = {}) => {
1727
const defaults: V1CourseWithCourseRuns = {
1828
id: uniqueCourseId.enforce(() => faker.number.int()),
@@ -70,6 +80,9 @@ const v1Course: PartialFactory<V1CourseWithCourseRuns> = (overrides = {}) => {
7080
product_flexible_price: null,
7181
},
7282
],
83+
enrollment_modes: Array.from({
84+
length: faker.number.int({ min: 1, max: 3 }),
85+
}).map(() => enrollmentMode()),
7386
approved_flexible_price_exists: faker.datatype.boolean(),
7487
},
7588
],
@@ -110,6 +123,9 @@ const courseRun: PartialFactory<CourseRunV2> = (overrides = {}) => {
110123
course_number: faker.lorem.word(),
111124
products: [product()],
112125
approved_flexible_price_exists: faker.datatype.boolean(),
126+
enrollment_modes: Array.from({
127+
length: faker.number.int({ min: 1, max: 3 }),
128+
}).map(() => enrollmentMode()),
113129
}
114130

115131
return mergeOverrides<CourseRunV2>(defaults, overrides)
@@ -183,4 +199,12 @@ const course: PartialFactory<CourseWithCourseRunsSerializerV2> = (
183199
const v1Courses = makePaginatedFactory(v1Course)
184200
const courses = makePaginatedFactory(course)
185201

186-
export { v1Course, v1Courses, course, courses, courseRun, product }
202+
export {
203+
v1Course,
204+
v1Courses,
205+
course,
206+
courses,
207+
courseRun,
208+
product,
209+
enrollmentMode,
210+
}

frontends/api/src/mitxonline/test-utils/factories/enrollment.ts

Lines changed: 5 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ import { faker } from "@faker-js/faker/locale/en"
22
import { mergeOverrides } from "ol-test-utilities"
33
import type { PartialFactory } from "ol-test-utilities"
44
import type {
5-
CourseRunEnrollment,
65
CourseRunEnrollmentRequestV2,
76
CourseRunGrade,
8-
UserProgramEnrollmentDetail,
97
V3UserProgramEnrollment,
108
} from "@mitodl/mitxonline-api-axios/v2"
119
import { UniqueEnforcer } from "enforce-unique"
12-
import { factories } from ".."
10+
import * as courses from "../factories/courses"
11+
import * as programs from "../factories/programs"
1312

1413
const uniqueEnrollmentId = new UniqueEnforcer()
1514
const uniqueRunId = new UniqueEnforcer()
@@ -49,6 +48,7 @@ const courseEnrollment: PartialFactory<CourseRunEnrollmentRequestV2> = (
4948
enrollment_mode: faker.helpers.arrayElement(["audit", "verified"]),
5049
edx_emails_subscription: faker.datatype.boolean(),
5150
run: {
51+
enrollment_modes: [courses.enrollmentMode()],
5252
id: uniqueRunId.enforce(() => faker.number.int()),
5353
title,
5454
start_date: faker.date.past().toISOString(),
@@ -134,70 +134,10 @@ const courseEnrollment: PartialFactory<CourseRunEnrollmentRequestV2> = (
134134
return mergeOverrides<CourseRunEnrollmentRequestV2>(defaults, overrides)
135135
}
136136

137-
// Type-safe conversion from V2 to V1 enrollment for compatibility
138-
const convertV2ToV1Enrollment = (
139-
v2Enrollment: CourseRunEnrollmentRequestV2,
140-
): CourseRunEnrollment => {
141-
// Remove V2-specific fields and return V1 compatible object
142-
const {
143-
b2b_contract_id: b2bContractId,
144-
b2b_organization_id: b2bOrganizationId,
145-
...v1Compatible
146-
} = v2Enrollment
147-
return v1Compatible as CourseRunEnrollment
148-
}
149-
150-
const programEnrollment: PartialFactory<UserProgramEnrollmentDetail> = (
151-
overrides = {},
152-
): UserProgramEnrollmentDetail => {
153-
const defaults: UserProgramEnrollmentDetail = {
154-
certificate: faker.datatype.boolean()
155-
? {
156-
uuid: faker.string.uuid(),
157-
link: faker.internet.url(),
158-
}
159-
: null,
160-
program: {
161-
id: faker.number.int(),
162-
title: faker.lorem.words(3),
163-
readable_id: faker.lorem.slug(),
164-
courses: factories.courses.v1Course(),
165-
requirements: {
166-
required: [faker.number.int()],
167-
electives: [faker.number.int()],
168-
},
169-
req_tree: [],
170-
page: {
171-
feature_image_src: faker.image.url(),
172-
page_url: faker.internet.url(),
173-
financial_assistance_form_url: faker.internet.url(),
174-
description: faker.lorem.paragraph(),
175-
live: faker.datatype.boolean(),
176-
length: `${faker.number.int({ min: 1, max: 12 })} weeks`,
177-
effort: `${faker.number.int({ min: 1, max: 10 })} hours/week`,
178-
price: faker.commerce.price(),
179-
},
180-
program_type: faker.helpers.arrayElement([
181-
"certificate",
182-
"degree",
183-
"diploma",
184-
]),
185-
departments: [
186-
{
187-
name: faker.company.name(),
188-
},
189-
],
190-
live: faker.datatype.boolean(),
191-
},
192-
enrollments: [convertV2ToV1Enrollment(courseEnrollment())],
193-
}
194-
return mergeOverrides<UserProgramEnrollmentDetail>(defaults, overrides)
195-
}
196-
197137
const programEnrollmentV3: PartialFactory<V3UserProgramEnrollment> = (
198138
overrides = {},
199139
): V3UserProgramEnrollment => {
200-
const program = factories.programs.simpleProgram()
140+
const program = programs.simpleProgram()
201141
const hasCertificate = faker.datatype.boolean()
202142
const defaults: V3UserProgramEnrollment = {
203143
certificate: hasCertificate
@@ -217,10 +157,4 @@ const courseEnrollments = (count: number): CourseRunEnrollmentRequestV2[] => {
217157
return new Array(count).fill(null).map(() => courseEnrollment())
218158
}
219159

220-
export {
221-
courseEnrollment,
222-
courseEnrollments,
223-
grade,
224-
programEnrollment,
225-
programEnrollmentV3,
226-
}
160+
export { courseEnrollment, courseEnrollments, grade, programEnrollmentV3 }

frontends/api/src/mitxonline/test-utils/factories/programs.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
import { mergeOverrides, makePaginatedFactory } from "ol-test-utilities"
2-
import type { PartialFactory } from "ol-test-utilities"
2+
import type { Factory, PartialFactory } from "ol-test-utilities"
33
import type {
4-
V2Program,
4+
BaseProgram,
55
V2ProgramCollection,
6+
V2ProgramDetail,
67
V3SimpleProgram,
78
} from "@mitodl/mitxonline-api-axios/v2"
89
import { faker } from "@faker-js/faker/locale/en"
910
import { UniqueEnforcer } from "enforce-unique"
11+
import * as courses from "./courses"
1012

1113
const uniqueProgramId = new UniqueEnforcer()
1214

13-
const program: PartialFactory<V2Program> = (overrides = {}) => {
14-
const defaults: V2Program = {
15+
const baseProgram: Factory<BaseProgram> = (overrides = {}) => {
16+
const defaults: BaseProgram = {
17+
title: faker.lorem.words(3),
18+
id: uniqueProgramId.enforce(() => faker.number.int()),
19+
readable_id: faker.lorem.slug(),
20+
type: faker.lorem.words(),
21+
}
22+
return {
23+
...defaults,
24+
...overrides,
25+
}
26+
}
27+
28+
const program: PartialFactory<V2ProgramDetail> = (overrides = {}) => {
29+
const defaults: V2ProgramDetail = {
1530
id: uniqueProgramId.enforce(() => faker.number.int()),
1631
title: faker.lorem.words(3),
1732
readable_id: faker.lorem.slug(),
@@ -77,10 +92,12 @@ const program: PartialFactory<V2Program> = (overrides = {}) => {
7792
enrollment_end: faker.helpers.maybe(() =>
7893
faker.date.future().toISOString(),
7994
),
95+
enrollment_modes: [courses.enrollmentMode()],
8096
end_date: faker.helpers.maybe(() => faker.date.future().toISOString()),
97+
products: [courses.product()],
8198
}
8299

83-
return mergeOverrides<V2Program>(defaults, overrides)
100+
return mergeOverrides<V2ProgramDetail>(defaults, overrides)
84101
}
85102

86103
const programs = makePaginatedFactory(program)
@@ -122,4 +139,4 @@ const simpleProgram: PartialFactory<V3SimpleProgram> = (overrides = {}) => {
122139
return mergeOverrides<V3SimpleProgram>(defaults, overrides)
123140
}
124141

125-
export { program, programs, programCollection, simpleProgram }
142+
export { baseProgram, program, programs, programCollection, simpleProgram }

frontends/jest-shared-setup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import { faker } from "@faker-js/faker/locale/en"
12
import failOnConsole from "jest-fail-on-console"
3+
4+
const seed = process.env.FAKER_SEED
5+
? parseInt(process.env.FAKER_SEED, 10)
6+
: Math.floor(Math.random() * 0xffffffff)
7+
faker.seed(seed)
8+
console.info(`Faker seed: ${seed} → FAKER_SEED=${seed} to reproduce`)
9+
210
import "@testing-library/jest-dom"
311
import "cross-fetch/polyfill"
412
import { resetAllWhenMocks } from "jest-when"

frontends/main/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"@emotion/styled": "^11.11.0",
1515
"@floating-ui/react": "^0.27.16",
1616
"@mitodl/course-search-utils": "^3.5.1",
17-
"@mitodl/mitxonline-api-axios": "^2026.2.19",
17+
"@mitodl/mitxonline-api-axios": "^2026.3.3",
1818
"@mitodl/smoot-design": "^6.24.0",
1919
"@mui/material": "^6.4.5",
2020
"@mui/material-nextjs": "^6.4.3",

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user"
3535
import { useQuery } from "@tanstack/react-query"
3636
import { coursePageView, programPageView, programView } from "@/common/urls"
3737
import { mitxonlineUrl } from "@/common/mitxonline"
38-
import { useAddToBasket, useClearBasket } from "api/mitxonline-hooks/baskets"
38+
import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets"
3939
import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers"
4040
import {
4141
CourseWithCourseRunsSerializerV2,
@@ -527,20 +527,14 @@ const UpgradeBanner: React.FC<
527527
onError,
528528
...others
529529
}) => {
530-
const addToBasket = useAddToBasket()
531-
const clearBasket = useClearBasket()
530+
const replaceBasketItem = useReplaceBasketItem()
532531

533532
const handleUpgradeClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
534533
e.preventDefault()
535534
if (!productId) return
536535

537536
try {
538-
// Reset mutation state to allow retry after error
539-
addToBasket.reset()
540-
clearBasket.reset()
541-
542-
await clearBasket.mutateAsync()
543-
await addToBasket.mutateAsync(productId)
537+
await replaceBasketItem.mutateAsync(productId)
544538
} catch (error) {
545539
onError?.(error as Error)
546540
}

0 commit comments

Comments
 (0)