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
2 changes: 1 addition & 1 deletion .github/workflows/publish-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 0.80.14
---------------

- adding link to program letter in dashboard (#3965)
- Update actions/deploy-pages action to v5 (#3897)
- feat(product-pages): render CMS-managed FAQs on course and program pages (#3954)
- feat: show a learner's own program price as an applied-savings breakdown (#3943)

Version 0.80.13
---------------

Expand Down
11 changes: 5 additions & 6 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,9 @@
"learning_resources/serializers.py:965:20:ORM004",
"learning_resources_search/serializers.py:661:4:ORM005",
"learning_resources_search/serializers.py:663:4:ORM005",
"profiles/serializers.py:104:39:ORM006",
"profiles/serializers.py:104:60:ORM006",
"profiles/serializers.py:109:19:ORM006",
"profiles/serializers.py:250:45:ORM004",
"profiles/serializers.py:256:45:ORM004",
"profiles/serializers.py:399:8:ORM006"
"profiles/serializers.py:109:39:ORM006",
"profiles/serializers.py:109:60:ORM006",
"profiles/serializers.py:114:19:ORM006",
"profiles/serializers.py:255:45:ORM004",
"profiles/serializers.py:261:45:ORM004"
]
2 changes: 1 addition & 1 deletion frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"dependencies": {
"@mitodl/mit-learn-api-axios": "2026.8.17",
"@mitodl/mitxonline-api-axios": "2026.9.16",
"@mitodl/mitxonline-api-axios": "2026.9.21",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.12.2",
"tiny-invariant": "^1.3.3"
Expand Down
8 changes: 8 additions & 0 deletions frontends/api/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
UsersApi,
NewsEventsApi,
ProfilesApi,
ProgramCertificatesApi,
TestimonialsApi,
LearningResourcesSearchAdminParamsApi,
VectorLearningResourcesSearchApi,
Expand Down Expand Up @@ -93,6 +94,12 @@ const usersApi = new UsersApi(undefined, BASE_PATH, axiosInstance)

const profilesApi = new ProfilesApi(undefined, BASE_PATH, axiosInstance)

const programCertificatesApi = new ProgramCertificatesApi(
undefined,
BASE_PATH,
axiosInstance,
)

const schoolsApi = new SchoolsApi(undefined, BASE_PATH, axiosInstance)

const newsEventsApi = new NewsEventsApi(undefined, BASE_PATH, axiosInstance)
Expand Down Expand Up @@ -125,6 +132,7 @@ export {
mediaApi,
hubspotApi,
offerorsApi,
programCertificatesApi,
programLettersApi,
learningResourcesSearchApi,
channelsApi,
Expand Down
35 changes: 8 additions & 27 deletions frontends/api/src/generated/v1/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions frontends/api/src/hooks/programCertificates/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query"
import { programCertificatesApi } from "../../clients"

const programCertificateKeys = {
root: ["programCertificates"],
list: () => [...programCertificateKeys.root, "list"],
}

const programCertificateQueries = {
/**
* The signed-in user's MicroMasters program certificates, each carrying the
* shareable `program_letter_share_url` for its program letter.
*
* Requesting this creates any program letters the user does not have yet —
* the letter uuid is minted on first read, by design — so it is only fetched
* where a letter link is actually rendered.
*/
list: () =>
queryOptions({
queryKey: programCertificateKeys.list(),
queryFn: () =>
programCertificatesApi
.programCertificatesList()
.then((res) => res.data),
}),
}

export { programCertificateQueries, programCertificateKeys }
31 changes: 26 additions & 5 deletions frontends/api/src/mitxonline/hooks/products/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,43 @@ import { productsApi } from "../../clients"

const productsKeys = {
root: ["mitxonline", "products"],
userFlexiblePrice: (opts: { productId: number }) => [
userPricing: (opts: { productId: number }) => [
...productsKeys.root,
"product",
opts,
"flexibleDetail",
"userPricing",
],
}

const productQueries = {
userFlexiblePriceDetail: (opts: { productId: number }) =>
/**
* What checkout charges this user for this product, plus the discount that
* gets them there.
*
* Rejects anonymous requests. A caller a visitor can reach — a public product
* page — gates on authentication to avoid firing a request certain to fail.
* Behind an authenticated route that gate buys nothing: a disabled query and
* a 401 under `throwOnError: false` both leave `data` undefined, so the
* caller falls back to the list price either way. What every caller does owe
* is that fallback.
*/
userPricingDetail: (opts: { productId: number }) =>
queryOptions({
queryKey: productsKeys.userFlexiblePrice(opts),
queryKey: productsKeys.userPricing(opts),
queryFn: () =>
productsApi
.productsUserFlexiblePriceRetrieve({ id: opts.productId })
.productsUserPricingRetrieve({ id: opts.productId })
.then((r) => r.data),
// Not cacheable like the rest of the app's content. The browser client
// defaults staleTime to the CDN TTL, which getQueryClient justifies with
// "most content is stable for ~24 hours (ETL cadence)" — true of a
// course, false of one learner's price. A program-child-purchase credit
// appears the moment they buy a child course, and their aid tier can be
// approved between two page views. Nothing invalidates productsKeys, so
// without this a stale quote is served until the window elapses.
// baskets/queries.ts and useComplianceGate do the same for the same
// reason.
staleTime: 0,
}),
}

Expand Down
15 changes: 14 additions & 1 deletion frontends/api/src/mitxonline/test-utils/factories/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CoursePageList,
V2Course,
ProgramPageItem,
FAQItem,
} from "@mitodl/mitxonline-api-axios/v2"
import { UniqueEnforcer } from "enforce-unique"
import { program } from "./programs"
Expand Down Expand Up @@ -58,6 +59,16 @@ const faculty: Factory<Faculty> = (override) => {
}
}

const uniqueFaqId = new UniqueEnforcer()
const faqItem: Factory<FAQItem> = (override) => {
return {
id: uniqueFaqId.enforce(() => faker.number.int()),
question: `${faker.lorem.sentence().replace(/\.$/, "")}?`,
answer: makeHTMLParagraph(1),
...override,
}
}

const uniqueCourseId = new UniqueEnforcer()

const v2Course: PartialFactory<V2Course> = (overrides = {}) => {
Expand Down Expand Up @@ -129,6 +140,7 @@ const coursePageItem: PartialFactory<CoursePageItem> = (override) => {
),
faculty_section_title: "About the Faculty",
faq_url: faker.internet.url(),
faqs: Array.from({ length: 3 }, () => faqItem()),
feature_image: featureImage(),
id: uniquePageId.enforce(() => faker.number.int()),
include_in_learn_catalog: faker.datatype.boolean(),
Expand Down Expand Up @@ -242,6 +254,7 @@ const programPageItem: PartialFactory<ProgramPageItem> = (override) => {
hubspot_form_id: faker.datatype.boolean() ? faker.string.uuid() : "",
prerequisites: makeHTMLParagraph(1),
faq_url: faker.internet.url(),
faqs: Array.from({ length: 3 }, () => faqItem()),
about: makeHTMLParagraph(3),
what_you_learn: makeHTMLList(5),
how_youll_learn: [
Expand Down Expand Up @@ -316,4 +329,4 @@ const programPageItem: PartialFactory<ProgramPageItem> = (override) => {
return mergeOverrides<ProgramPageItem>(defaults, override)
}

export { coursePageItem, coursePageList, faculty, programPageItem }
export { coursePageItem, coursePageList, faculty, faqItem, programPageItem }
67 changes: 58 additions & 9 deletions frontends/api/src/mitxonline/test-utils/factories/products.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { mergeOverrides } from "ol-test-utilities"
import type { PartialFactory } from "ol-test-utilities"
import type {
ProductFlexiblePrice,
DiscountSource,
UserPricingDiscount,
UserPricingProduct,
V0Discount,
} from "@mitodl/mitxonline-api-axios/v2"
import {
DiscountSourceTypeEnum,
DiscountTypeEnum,
RedemptionTypeEnum,
} from "@mitodl/mitxonline-api-axios/v2"
Expand All @@ -13,6 +16,14 @@ import { UniqueEnforcer } from "enforce-unique"

const uniqueDiscountId = new UniqueEnforcer()

/**
* A learner's approved financial-assistance discount, as it appears in
* `user_pricing`'s `product_flexible_price` field, which says whether aid is
* approved and at which tier even when another discount wins the quote.
*
* `paid-amount-off` is excluded: that type carries a stored amount of zero and
* a per-learner resolved value, so it is never a flexible price.
*/
const discount: PartialFactory<V0Discount> = (overrides = {}) => {
const defaults: V0Discount = {
id: uniqueDiscountId.enforce(() => faker.number.int()),
Expand All @@ -30,25 +41,63 @@ const discount: PartialFactory<V0Discount> = (overrides = {}) => {
]),
max_redemptions: faker.number.int({ min: 1, max: 100 }),
discount_code: faker.string.alphanumeric(8).toUpperCase(),
payment_type: null,
is_redeemed: faker.datatype.boolean(),
activation_date: faker.date.past().toISOString(),
expiration_date: faker.date.future().toISOString(),
}
return mergeOverrides<V0Discount>(defaults, overrides)
}

const flexiblePrice: PartialFactory<ProductFlexiblePrice> = (
/** The prior purchase a paid-amount-off credit spends. */
const discountSource: PartialFactory<DiscountSource> = (overrides = {}) => {
const defaults: DiscountSource = {
type: DiscountSourceTypeEnum.Course,
readable_id: `course-v1:MITx+${faker.string.alphanumeric(6)}`,
title: faker.company.catchPhrase(),
}
return mergeOverrides<DiscountSource>(defaults, overrides)
}

/**
* The discount checkout would apply. Defaults to the program-child purchase
* credit, the only type that names a source; pass `discount_type` and
* `source: null` for any other kind, and `payment_type` to make it the
* learner's financial-assistance tier. `payment_type` is left off by default:
* a discount created without one reports null, which the generated type spells
* as absent.
*/
const userPricingDiscount: PartialFactory<UserPricingDiscount> = (
overrides = {},
) => {
const defaults: ProductFlexiblePrice = {
const defaults: UserPricingDiscount = {
id: uniqueDiscountId.enforce(() => faker.number.int()),
discount_code: faker.string.alphanumeric(8).toUpperCase(),
discount_type: DiscountTypeEnum.PaidAmountOff,
amount_off: faker.commerce.price({ min: 10, max: 100 }),
source: discountSource(),
}
return mergeOverrides<UserPricingDiscount>(defaults, overrides)
}

/**
* A product priced for one user. Defaults to list price with no discount and
* no approved financial assistance, which is what most learners see.
*/
const userPricing: PartialFactory<UserPricingProduct> = (overrides = {}) => {
// Read the caller's price before building the defaults, so `user_price` is
// the list-price quote this factory advertises rather than an unrelated
// figure — which anything rendering `user_price` would show as a discount.
const price = overrides.price ?? faker.commerce.price()
const defaults: UserPricingProduct = {
id: faker.number.int(),
price: faker.commerce.price(),
price,
description: faker.lorem.sentence(),
is_active: faker.datatype.boolean(),
product_flexible_price: discount(),
is_active: true,
product_flexible_price: null,
user_price: price,
discount: null,
}
return mergeOverrides<ProductFlexiblePrice>(defaults, overrides)
return mergeOverrides<UserPricingProduct>(defaults, overrides)
}

export { discount, flexiblePrice }
export { discount, discountSource, userPricing, userPricingDiscount }
4 changes: 2 additions & 2 deletions frontends/api/src/mitxonline/test-utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ const certificates = {
}

const products = {
userFlexiblePriceDetail: (productId: number) =>
`${getApiBaseUrl()}/api/v0/products/${productId}/user_flexible_price/`,
userPricingDetail: (productId: number) =>
`${getApiBaseUrl()}/api/v0/products/${productId}/user_pricing/`,
}

const baskets = {
Expand Down
1 change: 1 addition & 0 deletions frontends/api/src/test-utils/factories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * as mitxonline from "./mitxonline"
export * as newsEvents from "./newsEvents"
export * as percolateQueries from "./percolateQueries"
export * as letters from "./programLetters"
export * as programCertificates from "./programCertificates"
export * as userLists from "./userLists"
export * as user from "./user"
export * as testimonials from "./testimonials"
17 changes: 17 additions & 0 deletions frontends/api/src/test-utils/factories/programCertificates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { faker } from "@faker-js/faker/locale/en"
import type { Factory } from "ol-test-utilities"
import type { ProgramCertificate } from "../../generated/v0"

const programCertificate: Factory<ProgramCertificate> = (overrides = {}) => ({
record_hash: faker.string.uuid(),
program_title: faker.lorem.words(),
user_full_name: faker.person.fullName(),
user_email: faker.internet.email(),
micromasters_program_id: faker.number.int(),
mitxonline_program_id: faker.number.int(),
program_letter_generate_url: new URL(faker.internet.url()).toString(),
program_letter_share_url: new URL(faker.internet.url()).toString(),
...overrides,
})

export { programCertificate }
Loading
Loading