diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml index a296d77cf4..bc1044cec8 100644 --- a/.github/workflows/publish-pages.yml +++ b/.github/workflows/publish-pages.yml @@ -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 diff --git a/RELEASE.rst b/RELEASE.rst index a8163593e0..e05a6b42e7 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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 --------------- diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index be586e00b5..104d751164 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -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" ] diff --git a/frontends/api/package.json b/frontends/api/package.json index bfe64ffdb6..1465eb6261 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -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" diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index 9bb8b646f6..1d99b3c8fd 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -25,6 +25,7 @@ import { UsersApi, NewsEventsApi, ProfilesApi, + ProgramCertificatesApi, TestimonialsApi, LearningResourcesSearchAdminParamsApi, VectorLearningResourcesSearchApi, @@ -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) @@ -125,6 +132,7 @@ export { mediaApi, hubspotApi, offerorsApi, + programCertificatesApi, programLettersApi, learningResourcesSearchApi, channelsApi, diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index faac07deab..ec37520483 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -2858,39 +2858,20 @@ export interface Program { course_count: number program_count: number } -/** - * Serializer for Program Certificates - */ -export interface ProgramCertificate { - record_hash: string - program_letter_generate_url: string - program_letter_share_url: string - program_title: string - user_full_name?: string - user_email: string - user_edxorg_id?: number | null - micromasters_program_id?: number | null - mitxonline_program_id?: number | null - user_edxorg_username?: string | null - user_gender?: string | null - user_address_city?: string | null - user_first_name?: string | null - user_last_name?: string | null - user_year_of_birth?: string | null - user_country?: string | null - user_address_postal_code?: string | null - user_street_address?: string | null - user_address_state_or_territory?: string | null - user_mitxonline_username?: string | null - program_completion_timestamp?: string | null -} /** * Serializer for Program Letters */ export interface ProgramLetter { id: string template_fields: ProgramLetterTemplateField - certificate: ProgramCertificate + certificate: ProgramLetterCertificate +} +/** + * The certificate fields the public program letter view needs. ProgramLetterViewSet is unauthenticated -- anyone holding a letter\'s uuid can read it -- so this exposes only what the letter itself already states: who earned it and which program. The learner\'s email, postal address, date of birth, gender and platform usernames stay behind the authenticated certificate list, which uses ProgramCertificateSerializer. + */ +export interface ProgramLetterCertificate { + user_full_name?: string + program_title: string } /** * Seriializer for program letter template data which is configured in micromasters diff --git a/frontends/api/src/hooks/programCertificates/queries.ts b/frontends/api/src/hooks/programCertificates/queries.ts new file mode 100644 index 0000000000..ecc140bb0d --- /dev/null +++ b/frontends/api/src/hooks/programCertificates/queries.ts @@ -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 } diff --git a/frontends/api/src/mitxonline/hooks/products/queries.ts b/frontends/api/src/mitxonline/hooks/products/queries.ts index 22eec5f681..f693a9afba 100644 --- a/frontends/api/src/mitxonline/hooks/products/queries.ts +++ b/frontends/api/src/mitxonline/hooks/products/queries.ts @@ -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, }), } diff --git a/frontends/api/src/mitxonline/test-utils/factories/pages.ts b/frontends/api/src/mitxonline/test-utils/factories/pages.ts index 8a1a79d4f1..c579d20589 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/pages.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/pages.ts @@ -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" @@ -58,6 +59,16 @@ const faculty: Factory = (override) => { } } +const uniqueFaqId = new UniqueEnforcer() +const faqItem: Factory = (override) => { + return { + id: uniqueFaqId.enforce(() => faker.number.int()), + question: `${faker.lorem.sentence().replace(/\.$/, "")}?`, + answer: makeHTMLParagraph(1), + ...override, + } +} + const uniqueCourseId = new UniqueEnforcer() const v2Course: PartialFactory = (overrides = {}) => { @@ -129,6 +140,7 @@ const coursePageItem: PartialFactory = (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(), @@ -242,6 +254,7 @@ const programPageItem: PartialFactory = (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: [ @@ -316,4 +329,4 @@ const programPageItem: PartialFactory = (override) => { return mergeOverrides(defaults, override) } -export { coursePageItem, coursePageList, faculty, programPageItem } +export { coursePageItem, coursePageList, faculty, faqItem, programPageItem } diff --git a/frontends/api/src/mitxonline/test-utils/factories/products.ts b/frontends/api/src/mitxonline/test-utils/factories/products.ts index 9aca2ecd27..f1b7385b41 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/products.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/products.ts @@ -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" @@ -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 = (overrides = {}) => { const defaults: V0Discount = { id: uniqueDiscountId.enforce(() => faker.number.int()), @@ -30,7 +41,6 @@ const discount: PartialFactory = (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(), @@ -38,17 +48,56 @@ const discount: PartialFactory = (overrides = {}) => { return mergeOverrides(defaults, overrides) } -const flexiblePrice: PartialFactory = ( +/** The prior purchase a paid-amount-off credit spends. */ +const discountSource: PartialFactory = (overrides = {}) => { + const defaults: DiscountSource = { + type: DiscountSourceTypeEnum.Course, + readable_id: `course-v1:MITx+${faker.string.alphanumeric(6)}`, + title: faker.company.catchPhrase(), + } + return mergeOverrides(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 = ( 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(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 = (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(defaults, overrides) + return mergeOverrides(defaults, overrides) } -export { discount, flexiblePrice } +export { discount, discountSource, userPricing, userPricingDiscount } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index f2f0d04da8..324273e9d6 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -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 = { diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index d349f89087..07be3f101a 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -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" diff --git a/frontends/api/src/test-utils/factories/programCertificates.ts b/frontends/api/src/test-utils/factories/programCertificates.ts new file mode 100644 index 0000000000..6fd32cdfa9 --- /dev/null +++ b/frontends/api/src/test-utils/factories/programCertificates.ts @@ -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 = (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 } diff --git a/frontends/api/src/test-utils/factories/programLetters.ts b/frontends/api/src/test-utils/factories/programLetters.ts index 29d332d130..511a4e7284 100644 --- a/frontends/api/src/test-utils/factories/programLetters.ts +++ b/frontends/api/src/test-utils/factories/programLetters.ts @@ -32,15 +32,8 @@ const programLetter: Factory = (overrides = {}) => ({ ], }, certificate: { - record_hash: faker.string.uuid(), - micromasters_program_id: faker.number.int(), - user_email: faker.internet.email(), program_title: faker.lorem.words(), - user_first_name: faker.person.firstName(), - user_last_name: faker.person.lastName(), user_full_name: faker.person.fullName(), - program_letter_generate_url: new URL(faker.internet.url()).toString(), - program_letter_share_url: new URL(faker.internet.url()).toString(), }, ...overrides, }) diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 8883ab5ff5..3dc5ca6508 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -223,6 +223,10 @@ const programLetters = { details: (id: string) => `${getApiBaseUrl()}/api/v1/program_letters/${id}/`, } +const programCertificates = { + list: () => `${getApiBaseUrl()}/api/v0/program_certificates/`, +} + const testimonials = { list: (params?: Paramsv0) => `${getApiBaseUrl()}/api/v0/testimonials/${query(params)}`, @@ -280,6 +284,7 @@ export { hubspot, search, userLists, + programCertificates, programLetters, channels, widgetLists, diff --git a/frontends/main/package.json b/frontends/main/package.json index ea95264947..1001ef5e62 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -18,7 +18,7 @@ "@mitodl/arithmix": "^0.2.5", "@mitodl/course-search-utils": "^3.8.1", "@mitodl/hacksnack": "^0.1.2", - "@mitodl/mitxonline-api-axios": "2026.9.16", + "@mitodl/mitxonline-api-axios": "2026.9.21", "@mitodl/smoot-design": "6.36.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx index c071cd82af..0bb300372d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -9,7 +9,11 @@ import { } from "@/test-utils" import { HomeEnrollmentsDisplay } from "./HomeEnrollmentsDisplay" import { CoursewareCard } from "./CoursewareCard" -import { setupEnrollments, setupOrderHistory } from "./test-utils" +import { + setupEnrollments, + setupOrderHistory, + setupProgramCertificates, +} from "./test-utils" import { formatRunIdentifier } from "./courseDateUtils" import * as mitxonline from "api/mitxonline-test-utils" import { makeRequest } from "api/test-utils" @@ -24,6 +28,7 @@ import { // Verified cards look up their order; default to none, tests override. beforeEach(() => { setupOrderHistory() + setupProgramCertificates() }) jest.mock("posthog-js/react") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx index a02ab5bf73..012aa3b924 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.test.tsx @@ -27,12 +27,17 @@ import { import { HomeEnrollmentsDisplay } from "./HomeEnrollmentsDisplay" import * as mitxonline from "api/mitxonline-test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" -import { setupEnrollments, setupOrderHistory } from "./test-utils" +import { + setupEnrollments, + setupOrderHistory, + setupProgramCertificates, +} from "./test-utils" import { faker } from "@faker-js/faker/locale/en" // Verified cards look up their order; default to none, tests override. beforeEach(() => { setupOrderHistory() + setupProgramCertificates() }) jest.mock("posthog-js/react") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index c99242aa87..ff3c3bfcfe 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -9,7 +9,7 @@ import { } from "@/test-utils" import { makeRequest } from "api/test-utils" import * as mitxonline from "api/mitxonline-test-utils" -import { setupOrderHistory } from "./test-utils" +import { setupOrderHistory, setupCoursePricing } from "./test-utils" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { waitFor } from "@testing-library/react" import invariant from "tiny-invariant" @@ -488,6 +488,7 @@ describe("ProgramAsCourseCard", () => { courseruns: [run], next_run_id: run.id, }) + setupCoursePricing(moduleWithRun) renderWithProviders( false) + // Verified cards look up their order; default to none, tests override. beforeEach(() => { setupOrderHistory() @@ -304,6 +313,108 @@ describe.each([ }) }) +describe("ProgramEnrollmentCard program letter", () => { + const PROGRAM_ID = 77 + const SHARE_URL = "https://learn.mit.edu/program_letter/some-uuid/view" + + const setup = ({ + flagEnabled, + mitxonlineProgramId, + }: { + flagEnabled: boolean + mitxonlineProgramId: number | null + }) => { + mockedUseFeatureFlagEnabled.mockReturnValue(flagEnabled) + setMockResponse.get(urls.programCertificates.list(), [ + programCertificateFactory.programCertificate({ + mitxonline_program_id: mitxonlineProgramId, + program_letter_share_url: SHARE_URL, + }), + ]) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: mitxonline.factories.programs.simpleProgram({ + id: PROGRAM_ID, + }), + }) + renderWithProviders( + , + ) + } + + const openMenu = async () => { + await user.click( + within(screen.getByTestId("enrollment-card-desktop")).getByRole( + "button", + { + name: "More options", + }, + ), + ) + // Program Record is unconditional, so its presence means the menu is open + // and a missing Program Letter is a real absence rather than a slow render. + await screen.findByRole("menuitem", { name: "Program Record" }) + } + + test("links to the letter's share url when the learner has a matching certificate", async () => { + setup({ flagEnabled: true, mitxonlineProgramId: PROGRAM_ID }) + await openMenu() + + expect( + await screen.findByRole("menuitem", { name: "Program Letter" }), + ).toHaveAttribute("href", SHARE_URL) + }) + + test("is hidden when no certificate matches this program", async () => { + setup({ flagEnabled: true, mitxonlineProgramId: PROGRAM_ID + 1 }) + await openMenu() + + expect( + screen.queryByRole("menuitem", { name: "Program Letter" }), + ).not.toBeInTheDocument() + }) + + test("is hidden when the certificate request fails, leaving the rest of the menu intact", async () => { + // Deliberate: a failed lookup is indistinguishable from having no + // certificate, because the failed response is both the letter url and the + // only signal that the learner has a letter. See useProgramLetterMenuItem. + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(urls.programCertificates.list(), null, { code: 500 }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: mitxonline.factories.programs.simpleProgram({ + id: PROGRAM_ID, + }), + }) + renderWithProviders( + , + ) + await openMenu() + + expect( + screen.queryByRole("menuitem", { name: "Program Letter" }), + ).not.toBeInTheDocument() + // The card itself must survive the failure. + expect( + screen.getByRole("menuitem", { name: "Program Record" }), + ).toBeInTheDocument() + }) + + test("is hidden, and no certificates are requested, when the flag is off", async () => { + // Requesting the list mints a shareable uuid for every letter the learner + // does not have yet, so it must not happen behind a disabled flag. + setup({ flagEnabled: false, mitxonlineProgramId: PROGRAM_ID }) + await openMenu() + + expect( + screen.queryByRole("menuitem", { name: "Program Letter" }), + ).not.toBeInTheDocument() + expect(makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ url: urls.programCertificates.list() }), + ) + }) +}) + // The progress badge only renders on the desktop card. describe("ProgramEnrollmentCard progress badge", () => { const getDesktopCard = () => screen.getByTestId("enrollment-card-desktop") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx index 680358f73a..3ece4332e0 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx @@ -30,6 +30,7 @@ import NiceModal from "@ebay/nice-modal-react" import { UnenrollProgramDialog } from "./DashboardDialogs" import { getReceiptMenuItem } from "./receiptMenuItem" import { useOrderIdForProgram } from "@/common/mitxonline/useOrderIdForResource" +import { useProgramLetterMenuItem } from "./hooks/useProgramLetterMenuItem" import { SimpleMenu, Stack } from "ol-components" import { EnrollmentStatus } from "./helpers" import { ProgressBadge } from "./ProgressBadge" @@ -66,6 +67,7 @@ export const ProgramEnrollmentCard = ({ * query with every other card on the dashboard. */ const receiptResolution = useOrderIdForProgram(programId) + const programLetterMenuItem = useProgramLetterMenuItem(programId) const displayMode = program.display_mode const titleSection = ( @@ -107,6 +109,7 @@ export const ProgramEnrollmentCard = ({ label: "Program Record", href: mitxonlineLegacyUrl(`/records/${programId}/`), }) + if (programLetterMenuItem) menuItems.push(programLetterMenuItem) if ( program.display_mode !== DisplayModeEnum.Course && !isVerifiedEnrollmentMode(programEnrollment.enrollment_mode) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx index b83b05025b..33b40c105e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentDisplay.test.tsx @@ -28,7 +28,7 @@ import { import { ProgramEnrollmentDisplay } from "./ProgramEnrollmentDisplay" import * as mitxonline from "api/mitxonline-test-utils" import { makeRequest } from "api/test-utils" -import { setupOrderHistory } from "./test-utils" +import { setupOrderHistory, setupCoursePricing } from "./test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" @@ -1058,6 +1058,7 @@ describe("ProgramEnrollmentDisplay", () => { ], } + setupCoursePricing(...courses.results) mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) // No enrollments setMockResponse.get( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.compliance.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.compliance.test.tsx index 9271d7a97a..a1e866a1d2 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.compliance.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.compliance.test.tsx @@ -12,6 +12,7 @@ import * as mitxonline from "api/mitxonline-test-utils" import { makeRequest } from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" import { UnenrolledCourseCard } from "./UnenrolledCourseCard" +import { setupCoursePricing } from "./test-utils" /** * Kept out of UnenrolledCourseCard.test.tsx deliberately. This is the only test @@ -56,6 +57,8 @@ describe("UnenrolledCourseCard — resuming enrollment after the compliance gate setMockResponse.get(mitxonline.urls.userMe.get(), userData) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + // The dialog's certificate upsell quotes every purchasable product. + setupCoursePricing(course) setMockResponse.get(mitxonline.urls.countries.list(), [ { code: "GB", name: "United Kingdom", states: [] }, ]) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx index 1cd3db06d9..8b23ff115b 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx @@ -11,6 +11,7 @@ import { } from "@/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { makeRequest } from "api/test-utils" +import { setupCoursePricing } from "./test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" @@ -22,7 +23,18 @@ jest.mock("@/common/analytics/gtm", () => ({ trackCourseEnrolled: jest.fn(), })) -const mitxOnlineCourse = mitxonline.factories.courses.course +/** + * A course, with a list-price quote registered for every purchasable product on + * its runs. The enrollment dialog's certificate upsell quotes each one, so a + * course without them fails any test that opens the dialog. + */ +const mitxOnlineCourse: typeof mitxonline.factories.courses.course = ( + overrides, +) => { + const course = mitxonline.factories.courses.course(overrides) + setupCoursePricing(course) + return course +} // The factory randomises is_staff, and staff bypass the start-date gate, which // would make these tests flaky. Staff tests pass it explicitly. diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useProgramLetterMenuItem.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useProgramLetterMenuItem.ts new file mode 100644 index 0000000000..6e491b0e7d --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useProgramLetterMenuItem.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { SimpleMenuItem } from "ol-components" +import { programCertificateQueries } from "api/hooks/programCertificates/queries" +import { FeatureFlags } from "@/common/feature_flags" + +/** + * The "Program Letter" item for a program card's menu, or null when the learner + * has no letter for that program. + * + * A program letter exists only for a MicroMasters program certificate, which + * reaches Learn through the warehouse sync rather than from MITx Online, so the + * two are joined on the certificate's `mitxonline_program_id`. A certificate + * whose `mitxonline_program_id` is null has no card to attach to and is not + * reachable from the dashboard. + * + * The certificate list is only requested once the flag has resolved to true: + * reading it mints a shareable uuid for every letter the learner does not have + * yet, which should not happen for learners who cannot see the link. + */ +const useProgramLetterMenuItem = ( + mitxonlineProgramId: number, +): SimpleMenuItem | null => { + const flagEnabled = useFeatureFlagEnabled(FeatureFlags.ProgramLetters) + const { data: certificates } = useQuery({ + ...programCertificateQueries.list(), + // Not `!!flagEnabled`: the flag is undefined until PostHog resolves it, and + // only an explicit true should trigger the request. + enabled: flagEnabled === true, + }) + + const shareUrl = certificates?.find( + (certificate) => certificate.mitxonline_program_id === mitxonlineProgramId, + )?.program_letter_share_url + + if (!shareUrl) return null + + return { + className: "dashboard-card-menu-item", + key: "program-letter", + label: "Program Letter", + href: shareUrl, + } +} + +export { useProgramLetterMenuItem } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index 3d32942f25..8ea17d1778 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -25,6 +25,36 @@ const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade const makeContract = factories.contracts.contract +/** + * Mock the per-learner price quote the certificate upsell fetches for every + * purchasable product on the given courses. Required in any suite that opens + * CourseEnrollmentDialog on an upgradable run, or the unmocked request fails + * the test. Quotes default to list price with no discount; re-register one to + * exercise a discount. + * + * Takes whole courses, variadically, because that is what these suites hold. + * ProductPages/test-utils/userPricing.ts has the per-run and per-program forms + * the InfoBox suites want; the names are kept distinct so the two do not read + * as the same helper. + */ +const setupCoursePricing = ( + ...courses: CourseWithCourseRunsSerializerV2[] +): void => { + courses.forEach((course) => + (course.courseruns ?? []).forEach((run) => + (run.products ?? []).forEach((product) => + setMockResponse.get( + urls.products.userPricingDetail(product.id), + factories.products.userPricing({ + id: product.id, + price: product.price, + }), + ), + ), + ), + ) +} + /** * Mock the order history that enrollment cards fetch to decide whether to show a * "Receipt" item. Required in any suite rendering an enrollment card, whatever @@ -87,6 +117,26 @@ const setupOrderHistory = ({ return { orderId } } +/** + * Mock the program certificates a program card fetches to decide whether to show + * a "Program Letter" item. Only requested when the `program-letters` flag is on, + * so this is required in any suite that renders a program card with feature + * flags mocked true. Defaults to no certificates (no letter link); pass + * `mitxonlineProgramIds` to give those programs one. + */ +const setupProgramCertificates = ({ + mitxonlineProgramIds = [], +}: { mitxonlineProgramIds?: number[] } = {}) => { + setMockResponse.get( + u.urls.programCertificates.list(), + mitxonlineProgramIds.map((id) => + u.factories.programCertificates.programCertificate({ + mitxonline_program_id: id, + }), + ), + ) +} + const dashboardCourse: PartialFactory = ( ...overrides ) => { @@ -616,9 +666,11 @@ const buildProgramScenario = ( } export { + setupCoursePricing, dashboardCourse, dashboardProgram, setupOrderHistory, + setupProgramCertificates, setupEnrollments, setupProgramsAndCourses, setupOrgAndUser, diff --git a/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.test.tsx b/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.test.tsx new file mode 100644 index 0000000000..96279fbbb8 --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.test.tsx @@ -0,0 +1,87 @@ +import React from "react" +import user from "@testing-library/user-event" +import { renderWithProviders, screen } from "@/test-utils" +import AppliedSavingsCard from "./AppliedSavingsCard" +import type { AppliedSavings } from "./enrollTypes" + +const breakdown = ( + overrides: Partial = {}, +): AppliedSavings => ({ + fullPrice: "$899", + amountOff: "$300", + todaysPrice: "$599", + sourceTitle: null, + kind: "other", + ...overrides, +}) + +const INFO_BUTTON = { name: "About applied savings" } + +describe("AppliedSavingsCard", () => { + test("the three rows pair each label with its amount, and a discount with no rule behind it has nothing to explain", () => { + const savings = breakdown() + + renderWithProviders( + , + ) + + expect(screen.getAllByRole("term").map((el) => el.textContent)).toEqual([ + "Program price", + // No sub-label: this breakdown names no source and is not aid. + "Applied savings", + "Today’s price", + ]) + expect( + screen.getAllByRole("definition").map((el) => el.textContent), + ).toEqual([ + savings.fullPrice, + `minus − ${savings.amountOff}`, + savings.todaysPrice, + ]) + expect(screen.queryByRole("button", INFO_BUTTON)).toBeNull() + }) + + test("the first row is named for the product, so a course is priced as one", () => { + renderWithProviders( + , + ) + + // dt carries no accessible name of its own, so read the term's text. + expect(screen.getAllByRole("term")[0]).toHaveTextContent("Course price") + expect(screen.queryByText("Program price")).toBeNull() + }) + + test("a purchase credit names the course and explains the rule", async () => { + const savings = breakdown({ + kind: "credit", + sourceTitle: "Fundamentals of Deep Learning", + }) + + renderWithProviders( + , + ) + + expect(screen.getByText(savings.sourceTitle!)).toBeVisible() + await user.click(screen.getByRole("button", INFO_BUTTON)) + expect( + await screen.findByRole("dialog", { name: "Applied savings" }), + ).toHaveTextContent( + "One previous purchase from this program can be credited toward the full program's price.", + ) + }) + + test("financial aid names itself and gets its own explanation", async () => { + renderWithProviders( + , + ) + + expect(screen.getByText("Financial aid")).toBeVisible() + await user.click(screen.getByRole("button", INFO_BUTTON)) + expect( + await screen.findByRole("dialog", { name: "Applied savings" }), + ).toHaveTextContent("Based on your approved financial aid.") + }) +}) diff --git a/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.tsx b/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.tsx new file mode 100644 index 0000000000..6c6fb8ff6b --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.tsx @@ -0,0 +1,236 @@ +import React from "react" +import { styled, ActionButton, VisuallyHidden } from "@mitodl/smoot-design" +import { Popover } from "ol-components" +import { RiInformation2Line } from "@remixicon/react" +import { CardSurface } from "./TrackCard" +import type { AppliedSavings } from "./enrollTypes" + +const INFO_LABEL = "Applied savings" +/** The `(i)` button's name; the row beside it already reads INFO_LABEL. */ +const INFO_BUTTON_LABEL = "About applied savings" + +const INFO_BODY: Record = { + credit: + "One previous purchase from this program can be credited toward the full program's price.", + aid: "Based on your approved financial aid.", + // A sale or a personal code explains itself: the row already names an amount + // and there is no rule behind it the learner could act on. + other: null, +} + +const Rows = styled.dl({ + display: "flex", + flexDirection: "column", + gap: "16px", + width: "100%", + margin: 0, +}) + +/** + * One label/value pair. The amount is set larger than its term, so the two are + * aligned at the top and the amount's extra height hangs below — bottom or + * baseline alignment instead lifts the amount above its term. + */ +const Row = styled.div({ + display: "flex", + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "8px", + width: "100%", +}) + +const TotalRow = styled(Row)(({ theme }) => ({ + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + paddingTop: "16px", +})) + +const Label = styled.dt(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, +})) + +/** The sum's term, a step up the scale from the rows it totals. */ +const TotalLabel = styled(Label)(({ theme }) => ({ + ...theme.typography.subtitle1, +})) + +/** + * The deduction's term, which carries the discount's identity on a second line. + * Both lines live in the `dt` so a screen reader reads them as one term paired + * with one amount. + */ +const SavingsLabel = styled.dt({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "8px", +}) + +/** Lighter than the two labels it sits between. */ +const SavingsLabelText = styled.span(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, +})) + +const SubLabel = styled.div(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.darkGray2, +})) + +/** + * Centers the icon on the label's line box. Works because inline-flex is + * inline-level, so verticalAlign applies to the container itself. + */ +const InfoButtonContainer = styled.span(({ theme }) => ({ + marginLeft: "8px", + display: "inline-flex", + verticalAlign: "middle", + height: theme.typography.body2.lineHeight, + alignItems: "center", + "> button": { + color: theme.custom.colors.silverGrayDark, + }, +})) + +const InfoPopover = styled(Popover)({ + width: "300px", + maxWidth: "100vw", +}) + +const InfoBody = styled.p(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, + margin: 0, +})) + +/** + * The rule behind the deduction. A popover rather than a dialog: it explains + * one row, and the price it explains stays on screen beside it. + * + * Opening is bound to click, which covers pointer, touch and keyboard alike — + * hover would leave touch users with nothing to reach it by. Click-away and + * Escape close it, and focus returns to the icon. + * + * It opens upwards because the rows below it are the total this popover exists + * to explain; the default placement covers them at every width. Popper still + * flips it down when there is no room above. + */ +const InfoPopoverButton: React.FC<{ body: string }> = ({ body }) => { + const [anchor, setAnchor] = React.useState(null) + return ( + + setAnchor(event.currentTarget)} + > + + setAnchor(null)} + > + {body} + + + ) +} + +const Amount = styled.dd(({ theme }) => ({ + ...theme.typography.h5, + color: theme.custom.colors.darkGray2, + whiteSpace: "nowrap", + margin: 0, +})) + +/** + * Alone among the amounts this one drops to the foot of its term, so it reads + * against the source line rather than against the words "Applied savings" — the + * source is what the deduction is being taken for. + */ +const Deduction = styled(Amount)(({ theme }) => ({ + color: theme.custom.colors.green, + alignSelf: "flex-end", +})) + +const Total = styled(Amount)(({ theme }) => ({ + ...theme.typography.h4, +})) + +type AppliedSavingsCardProps = { + breakdown: AppliedSavings + /** Names the first row. A program presented as a course is priced as one. */ + productNoun: "course" | "program" + action?: React.ReactNode + fill?: boolean +} + +const FULL_PRICE_LABEL: Record = + { + course: "Course price", + program: "Program price", + } + +/** + * The paid offering box for a learner whose quote carries a discount: the + * program's price, what comes off it, and what checkout charges today. Replaces + * the Certificate Track card rather than decorating it — a learner with a real + * number has no use for the advertised one, or for the feature bullets beside + * it. + */ +const AppliedSavingsCard: React.FC = ({ + breakdown, + productNoun, + action, + fill, +}) => { + const { kind, sourceTitle } = breakdown + // Only aid names itself; a credit is named by the purchase it spends, and + // nothing else in the union carries a source to name. + const subLabel = sourceTitle ?? (kind === "aid" ? "Financial aid" : null) + const infoBody = INFO_BODY[kind] + + return ( + + + + + {breakdown.fullPrice} + + + + + {INFO_LABEL} + {infoBody ? : null} + + {subLabel ? {subLabel} : null} + + + {/* The sign carries the meaning, so it is text rather than colour + alone. U+2212, not a hyphen: it is cut to the width and height + of the digits beside it, where a hyphen reads as a stray mark + against a number this large. Screen readers do not announce + either one reliably, hence the visually hidden word. */} + minus + + {breakdown.amountOff} + + + + Today’s price + {breakdown.todaysPrice} + + + {action} + + ) +} + +export default AppliedSavingsCard diff --git a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx index d336183032..c565292650 100644 --- a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.test.tsx @@ -51,9 +51,9 @@ describe("CertificateTrackCard", () => { linkText: "Apply for financial aid", }, { - name: "applied (visible at checkout), when applied", + name: "approved, when applied", applied: true, - linkText: "Financial aid approved (visible at checkout)", + linkText: "Financial aid approved", }, ])( "renders financial aid link to the form — $name", diff --git a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx index 85cee2b0ac..aeee1e0392 100644 --- a/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx +++ b/frontends/main/src/app-pages/ProductPages/CertificateTrackCard.tsx @@ -1,44 +1,12 @@ import React from "react" -import { styled } from "@mitodl/smoot-design" -import { linkStyles } from "ol-components" import type { FinancialAid } from "./enrollTypes" +import { FinancialAidIndicator } from "./EnrollAreaParts" import TrackCard, { FeatureRow, FeatureIcon, AccessFeatureRow, } from "./TrackCard" -/** - * `linkStyles`' small "red" link is the body3 scale the card wants, and its red - * is the call-to-action colour for the unapproved state. The approved state - * keeps that scale and swaps only the colour, which Link has no variant for: - * green marks it as a resolved state rather than something to act on, so it also - * drops the resting underline and takes one on hover instead — it stays a link - * to the application record, but users have no reason to follow it. - */ -const FinancialAidLink = styled.a<{ $approved?: boolean }>( - linkStyles({ size: "small", color: "red" }), - ({ $approved, theme }) => - $approved - ? { - color: theme.custom.colors.green, - ":hover": { - color: theme.custom.colors.green, - textDecoration: "underline", - }, - } - : { textDecoration: "underline" }, -) - -/** - * Holds the aid link's row while the approval lookup is in flight, so resolving - * it does not shift the rest of the card. Sized by the link's own line box. - */ -const FinancialAidPlaceholder = styled.span(({ theme }) => ({ - display: "block", - height: theme.typography.body3.lineHeight, -})) - type CertificateTrackCardProps = { price: React.ReactNode compactPrice?: boolean @@ -68,18 +36,7 @@ const CertificateTrackCard: React.FC = ({ priceBlock={priceBlock} headerAside={ financialAid ? ( - financialAid.pending ? ( - - ) : ( - - {financialAid.applied - ? "Financial aid approved (visible at checkout)" - : "Apply for financial aid"} - - ) + ) : null } action={action} diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.test.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.test.tsx index 39a6b42476..948855fea4 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.test.tsx @@ -13,6 +13,11 @@ import { factories as mitxFactories, urls as mitxUrls, } from "api/mitxonline-test-utils" +import { makeCourse, setupRunPricing } from "./test-utils/userPricing" +import { + DiscountTypeEnum, + PaymentTypeEnum, +} from "@mitodl/mitxonline-api-axios/v2" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import CourseEnrollArea from "./CourseEnrollArea" import { getSelectedRun } from "./courseRun" @@ -32,11 +37,10 @@ jest.mock("@/common/analytics/gtm", () => ({ trackBeginCheckout: jest.fn(), })) -const makeCourse = mitxFactories.courses.course const makeRun = mitxFactories.courses.courseRun const makeMode = mitxFactories.courses.enrollmentMode const makeProduct = mitxFactories.courses.product -const makeFlexiblePrice = mitxFactories.products.flexiblePrice +const makeUserPricing = mitxFactories.products.userPricing const makeDiscount = mitxFactories.products.discount const makeUser = factories.user.user @@ -147,6 +151,78 @@ describe("CourseEnrollArea — paidOnly scenario", () => { }) }) +describe("CourseEnrollArea — applied savings", () => { + const aidRun = () => + makeRun({ + is_enrollable: true, + is_upgradable: true, + is_archived: false, + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "899" })], + }) + + test("an aid quote replaces the Certificate Track card, priced as a course", async () => { + setupAuth() + const run = aidRun() + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + setupRunPricing(run, { + user_price: "399", + discount: mitxFactories.products.userPricingDiscount({ + amount_off: "500", + // The factory defaults to paid-amount-off, which reads as a credit — + // a course product can never be quoted one. + discount_type: DiscountTypeEnum.DollarsOff, + payment_type: PaymentTypeEnum.FinancialAssistance, + // Only a credit names a source; the factory defaults one alongside + // paid-amount-off. + source: null, + }), + }) + + renderWithProviders( + , + ) + + // The action keeps the offering's own wording: only a credit upgrades you + // to a full program, and a course has none. + await screen.findByRole("button", { name: "Enroll" }) + const certCell = document.querySelector("[data-card='cert']") as HTMLElement + within(certCell).getByText("Course price") + within(certCell).getByText("$899") + within(certCell).getByText("$399") + within(certCell).getByText("Financial aid") + expect( + screen.queryByRole("heading", { name: "Certificate Track" }), + ).toBeNull() + expect(screen.queryByText("Program price")).toBeNull() + }) + + test("a quote that takes nothing off leaves the ordinary card alone", async () => { + setupAuth() + const run = aidRun() + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + // The backend reports a discount only when it beats list price, so this + // shape should not reach us; if it does, a "− $0" breakdown is the wrong + // answer. The guard is shared, so this pins it for the program page too. + setupRunPricing(run, { + user_price: "899", + discount: mitxFactories.products.userPricingDiscount({ + amount_off: "0", + discount_type: DiscountTypeEnum.DollarsOff, + source: null, + }), + }) + + renderWithProviders( + , + ) + + await screen.findByRole("button", { name: "Enroll" }) + screen.getByRole("heading", { name: "Certificate Track" }) + expect(screen.queryByText("Course price")).toBeNull() + }) +}) + describe("CourseEnrollArea — freeOnly scenario", () => { test("shows Learn for Free card; Start Learning button below; large", async () => { setupAuth() @@ -396,20 +472,31 @@ describe("CourseEnrollArea — financial assistance link", () => { test.each([ { name: "available, when aid not yet applied", - flexiblePrice: () => makeFlexiblePrice({ product_flexible_price: null }), + userPricing: () => makeUserPricing(), linkText: "Apply for financial aid", }, { - name: "approved (applied at checkout), when aid is approved", - // The factory already defaults product_flexible_price to a discount with a - // real id, which is the only field the "approved" state keys off - // (useCourseCertificatePrice: !!product_flexible_price?.id). - flexiblePrice: () => makeFlexiblePrice(), - linkText: "Financial aid approved (visible at checkout)", + name: "approved, when aid is approved and discounts the price", + // An approved learner is one with any product_flexible_price + // (useCertificatePricing: !!product_flexible_price). The quote must also + // take something off: an approval worth nothing says nothing, which the + // 0%-tier test below pins. + userPricing: () => + makeUserPricing({ + product_flexible_price: makeDiscount(), + user_price: "50", + discount: mitxFactories.products.userPricingDiscount({ + discount_type: DiscountTypeEnum.PercentOff, + payment_type: PaymentTypeEnum.FinancialAssistance, + amount_off: "50", + source: null, + }), + }), + linkText: "Financial aid approved", }, ])( "paidOnly course with financial_assistance_form_url shows link — $name", - async ({ flexiblePrice, linkText }) => { + async ({ userPricing, linkText }) => { setupAuth() const product = makeProduct() const run = makeRun({ @@ -426,8 +513,8 @@ describe("CourseEnrollArea — financial assistance link", () => { }) setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - flexiblePrice(), + mitxUrls.products.userPricingDetail(product.id), + userPricing(), ) renderWithProviders( @@ -446,16 +533,19 @@ describe("CourseEnrollArea — financial assistance link", () => { }, ) - test("paidOnly course with approved flexible price shows the full price, not a finaid discount", async () => { - // Case C2: financial aid is surfaced as text ("applied at checkout"), not by - // discounting the displayed price — so the full price shows and the flexible - // price's would-be discount ($100 - $25 = $75) is never rendered. + test("an approval that discounts nothing says nothing, and never subtracts the tier's stored amount", async () => { + // mitxonline's APPROVED means it accepted the declared income, not that the + // income earned anything: the top tier is 0% off and the aid form tells that + // learner they did not qualify. So neither label is true here — "approved" + // claims a success they did not get, "apply" is wrong because they already + // did — and the row renders nothing at all. + // + // The stored amount is also never subtracted: if the course path (wrongly) + // applied it, the display would read $75 instead of the full $100. Only the + // amount and type are under test; the factory fills the rest. setupAuth() const product = makeProduct({ price: "100" }) - // A real $25-off discount: if the course path (wrongly) applied it, the - // display would read $75 instead of the full $100. Only the amount and type - // are under test; the factory fills the rest. - const flexiblePrice = makeFlexiblePrice({ + const userPricing = makeUserPricing({ product_flexible_price: makeDiscount({ discount_type: "dollars-off", amount: "25.00", @@ -475,18 +565,26 @@ describe("CourseEnrollArea — financial assistance link", () => { }) setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - flexiblePrice, + mitxUrls.products.userPricingDetail(product.id), + userPricing, ) renderWithProviders( , ) - // Approved aid is surfaced as the text note, applied at checkout - await screen.findByRole("link", { - name: "Financial aid approved (visible at checkout)", + // Neither wording is true for this learner, so the row settles to nothing. + // "Apply" is what shows before the quote lands, so waiting for it to go is + // also what synchronises on the quote: the price alone cannot, since it + // reads $100 either side of the request. + await waitFor(() => { + expect( + screen.queryByRole("link", { name: "Apply for financial aid" }), + ).toBeNull() }) + expect( + screen.queryByRole("link", { name: "Financial aid approved" }), + ).toBeNull() // Full price shows; the flexible-price discount is not applied to the display expect(screen.getByText("$100")).toBeInTheDocument() expect(screen.queryByText("$75")).not.toBeInTheDocument() @@ -520,9 +618,10 @@ describe("CourseEnrollArea — advertised price range", () => { expect(screen.getByText("$250 – $1,000")).toBeInTheDocument() }) - test("an approved flexible price does not collapse the range to one number", async () => { - // The range is what anyone might pay; the user's own price is only settled at - // checkout, so approval does not narrow the display. + test("an approved flexible price collapses the range to the run's price", async () => { + // The range advertises the aid floor. A learner already quoted against it + // has nothing left to be advertised, so they see the single price, as on a + // program page. setupAuth() const product = makeProduct({ price: "1000" }) const run = makePaidRun(product) @@ -534,8 +633,8 @@ describe("CourseEnrollArea — advertised price range", () => { page: { financial_assistance_form_url: "/financial-aid/" }, }) setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - makeFlexiblePrice({ + mitxUrls.products.userPricingDetail(product.id), + makeUserPricing({ product_flexible_price: makeDiscount({ discount_type: "dollars-off", amount: "750.00", @@ -547,9 +646,10 @@ describe("CourseEnrollArea — advertised price range", () => { , ) - await screen.findByRole("link", { - name: "Financial aid approved (visible at checkout)", - }) - expect(screen.getByText("$250 – $1,000")).toBeInTheDocument() + // The collapse is the quote landing, so wait on the single price itself: + // this learner's approval discounts nothing, so there is no aid row to + // synchronise on. + expect(await screen.findByText("$1,000")).toBeInTheDocument() + expect(screen.queryByText("$250 – $1,000")).toBeNull() }) }) diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx index 1539ac2573..a6f9a8e620 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx @@ -3,9 +3,9 @@ import type { CourseRunV2, CourseWithCourseRunsSerializerV2, } from "@mitodl/mitxonline-api-axios/v2" -import { toPriceRange } from "@/common/mitxonline" +import { canPurchaseRun } from "@/common/mitxonline" import { useCourseEnrollment } from "./useCourseEnrollment" -import { useCourseCertificatePrice } from "./useCourseCertificatePrice" +import { useCertificatePricing } from "./useCertificatePricing" import EnrollOfferingBoxes from "./EnrollOfferingBoxes" type CourseEnrollAreaProps = { @@ -25,9 +25,11 @@ const CourseEnrollArea: React.FC = ({ onRequireSignup: setAnchor, }) - const { price, financialAid } = useCourseCertificatePrice(course, selectedRun) - // Same predicate the hook formats on, so the sizing tracks the display. - const isRange = toPriceRange(course) !== null + const { price, showsRange, financialAid, breakdown } = useCertificatePricing( + course, + selectedRun?.products?.[0], + { purchasable: selectedRun ? canPurchaseRun(selectedRun) : false }, + ) // Show the in-card "Certificate deadline passed" note in both degraded // states (deadlinePassed and archived), but only when the run actually @@ -43,8 +45,9 @@ const CourseEnrollArea: React.FC = ({ isPending={isPending} isError={isError} price={price} - compactPrice={isRange} + compactPrice={showsRange} financialAid={financialAid} + breakdown={breakdown} productNoun="course" certificateDeadlineNote={certificateDeadlineNote} anchor={anchor} diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 8e48db80f7..97504c97f8 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -3,6 +3,7 @@ import { urls as mitxUrls, factories as mitxFactories, } from "api/mitxonline-test-utils" +import { makeCourse } from "./test-utils/userPricing" import type { CoursePageItem, CourseWithCourseRunsSerializerV2, @@ -54,7 +55,6 @@ jest.mock("@/common/analytics/gtm", () => ({ trackCourseProgramView: jest.fn(), })) -const makeCourse = mitxFactories.courses.course const makePage = mitxFactories.pages.coursePageItem const expectRawContent = (el: HTMLElement, htmlString: string) => { @@ -165,6 +165,8 @@ describe("CoursePage", () => { { level: 2, name: "Prerequisites" }, { level: 2, name: "Meet your instructors" }, { level: 3, name: page.faculty[0].instructor_name }, + { level: 2, name: "FAQs" }, + ...page.faqs.map((faq) => ({ level: 3, name: faq.question })), ]) }) }) diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx index 14e420fc0e..c6fcfb74ea 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx @@ -12,6 +12,7 @@ import { FeatureFlags } from "@/common/feature_flags" import { notFound } from "next/navigation" import { getOutlineCoursewareId, HeadingIds } from "./util" import InstructorsSection from "./InstructorsSection" +import FaqsSection from "./FaqsSection" import RawHTML from "./RawHTML" import AboutSection from "./AboutSection" import ProductPageTemplate from "./ProductPageTemplate" @@ -154,6 +155,7 @@ const CoursePage: React.FC = ({ readableId }) => { {page.faculty.length ? ( ) : null} + {page.faqs.length ? : null} ) } diff --git a/frontends/main/src/app-pages/ProductPages/EnrollAreaParts.tsx b/frontends/main/src/app-pages/ProductPages/EnrollAreaParts.tsx index 1ba9f18c3f..36c991c859 100644 --- a/frontends/main/src/app-pages/ProductPages/EnrollAreaParts.tsx +++ b/frontends/main/src/app-pages/ProductPages/EnrollAreaParts.tsx @@ -1,14 +1,123 @@ import React from "react" -import { LoadingSpinner } from "ol-components" +import { LoadingSpinner, linkStyles } from "ol-components" import { Button, styled, type ButtonProps } from "@mitodl/smoot-design" -import { type EnrollAction } from "./enrollTypes" +import type { EnrollAction, FinancialAid } from "./enrollTypes" -export const ChooseYourPath = styled.div(({ theme }) => ({ +const OfferingHeading = styled.div(({ theme }) => ({ ...theme.typography.subtitle2, fontWeight: theme.typography.fontWeightBold, color: theme.custom.colors.darkGray2, })) +/** + * The heading and its aside wrap onto separate lines where they do not share + * one: the tablet grid gives this row a ~268px cell against the ~346px of the + * desktop sidebar, and "Continue with full program" beside "Apply for financial + * aid" only just fits at the wider size. + */ +const HeadingRow = styled.div({ + display: "flex", + flexWrap: "wrap", + alignItems: "baseline", + columnGap: "8px", + rowGap: "4px", +}) + +/** + * Holds the aside at the right edge whether it shares the heading's line or + * takes one of its own, where `justify-content: space-between` would left-align + * it once alone. + */ +const HeadingAside = styled.span({ marginLeft: "auto" }) + +/** Takes a line of its own whatever else in the row wrapped. */ +const HeadingNote = styled.div(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.darkGray2, + flexBasis: "100%", +})) + +type OfferingHeadingRowProps = { + /** The heading over an offering box, whatever that box turns out to be. */ + label?: string + /** Right-aligned beside the heading; the financial-aid indicator. */ + aside?: React.ReactNode + /** The line beneath the heading, naming why the price below it is reduced. */ + note?: React.ReactNode +} & React.HTMLAttributes + +/** + * The row above an offering box. Renders nothing when it has nothing to carry: + * an empty row would still be a flex item, and the cell's gap would open a + * blank band above the box. + */ +export const OfferingHeadingRow: React.FC = ({ + label, + aside, + note, + ...rest +}) => { + if (!label && !aside && !note) return null + return ( + + {label ? {label} : null} + {aside ? {aside} : null} + {note ? {note} : null} + + ) +} + +/** + * `linkStyles`' small "red" link is the body3 scale these rows want, and its red + * is the call-to-action colour for the unapproved state. The approved state + * keeps that scale and swaps only the colour, which Link has no variant for: + * green marks it as a resolved state rather than something to act on, so it also + * drops the resting underline and takes one on hover instead — it stays a link + * to the application record, but users have no reason to follow it. + */ +const FinancialAidLink = styled.a<{ $approved?: boolean }>( + linkStyles({ size: "small", color: "red" }), + ({ $approved, theme }) => + $approved + ? { + color: theme.custom.colors.green, + ":hover": { + color: theme.custom.colors.green, + textDecoration: "underline", + }, + } + : { textDecoration: "underline" }, +) + +/** + * Holds the aid link's row while the approval lookup is in flight, so resolving + * it does not shift the box it sits in. Sized by the link's own line box, which + * `linkStyles`' small scale resolves to body3. + */ +const FinancialAidPlaceholder = styled.span(({ theme }) => ({ + display: "block", + height: theme.typography.body3.lineHeight, +})) + +/** + * The financial-aid indicator for a paid offering box: a link to the form, + * worded by approval. "Approved" rather than "applied" because it is true + * whatever the quote did with the tier — won with it, lost it to a cheaper + * discount, or priced at list from a 0% tier. + */ +export const FinancialAidIndicator: React.FC<{ + financialAid: FinancialAid +}> = ({ financialAid }) => + financialAid.pending ? ( + + ) : ( + + {financialAid.applied + ? "Financial aid approved" + : "Apply for financial aid"} + + ) + const ButtonWrapper = styled.span<{ $fullWidth?: boolean }>( ({ $fullWidth, theme }) => ({ ...($fullWidth diff --git a/frontends/main/src/app-pages/ProductPages/EnrollOfferingBoxes.tsx b/frontends/main/src/app-pages/ProductPages/EnrollOfferingBoxes.tsx index 198771e21d..6196e31fa6 100644 --- a/frontends/main/src/app-pages/ProductPages/EnrollOfferingBoxes.tsx +++ b/frontends/main/src/app-pages/ProductPages/EnrollOfferingBoxes.tsx @@ -2,15 +2,22 @@ import React from "react" import { Alert } from "@mitodl/smoot-design" import { SignupPopover } from "@/page-components/SignupPopover/SignupPopover" import CertificateTrackCard from "./CertificateTrackCard" +import AppliedSavingsCard from "./AppliedSavingsCard" import LearnForFreeCard from "./LearnForFreeCard" import EnrolledLink from "./EnrolledLink" import { EnrollButton, OfferingCell, - ChooseYourPath, + OfferingHeadingRow, FullRowCell, + FinancialAidIndicator, } from "./EnrollAreaParts" -import type { EnrollAreaState, FinancialAid, Offering } from "./enrollTypes" +import type { + AppliedSavings, + EnrollAreaState, + FinancialAid, + Offering, +} from "./enrollTypes" type EnrollOfferingBoxesProps = { /** Actionable offering — drives which boxes render and the "both" layout. */ @@ -26,6 +33,15 @@ type EnrollOfferingBoxesProps = { /** Full-width price presentation; suppresses `price` (program savings only). */ priceBlock?: React.ReactNode financialAid: FinancialAid | null + /** The learner's own price quote; replaces the certificate card. */ + breakdown?: AppliedSavings | null + /** + * Heading over the paid box in the paid-only layout, which has none by + * default. The side-by-side layout is headed "Choose Your Path" regardless. + */ + paidHeading?: string + /** A line beneath the heading row, naming why the paid price is reduced. */ + paidNote?: React.ReactNode productNoun: "course" | "program" /** Course-only: show the "Certificate deadline passed" note in the free card. */ certificateDeadlineNote?: boolean @@ -39,7 +55,8 @@ type EnrollOfferingBoxesProps = { * variants: the enrolled collapse, the paid/free cards with their buttons * (side-by-side under "Choose Your Path" when both paths exist), the * enrollment-failure alert, and the signup popover. Callers own the hooks and - * pass data; this component owns the box structure and button conventions. + * pass data; this component owns the box structure and button conventions, and + * knows nothing about why a price is what it is. */ const EnrollOfferingBoxes: React.FC = ({ offering, @@ -51,6 +68,9 @@ const EnrollOfferingBoxes: React.FC = ({ compactPrice, priceBlock, financialAid, + breakdown, + paidHeading, + paidNote, productNoun, certificateDeadlineNote, anchor, @@ -75,51 +95,62 @@ const EnrollOfferingBoxes: React.FC = ({ const options = state.options const paidAction = options.find((o) => o.kind === "paid") const freeAction = options.find((o) => o.kind === "free") + const sideBySide = offering === "both" + + // The certificate card carries the aid indicator in its own header. The + // savings card that replaces it has no header, so the indicator moves up to + // the heading row. It cannot be pending here: a breakdown only exists once + // the same lookup has resolved. + const headingAside = + breakdown && financialAid ? ( + + ) : null const renderPaidBox = () => { if (!paidAction) return null - if (offering === "both") { - // Button inside card. fill: match the free card's height in the - // side-by-side layout and bottom-align the button. - return ( - + // Every layout but the ordinary paid-only card carries its button inside the + // box; that one alone puts it below, at the larger size a lone action takes. + const buttonInBox = sideBySide || !!breakdown + const button = ( + + ) + return ( + + {sideBySide ? null : ( + // The side-by-side layout's heading is a grid child of its own. + + )} + {breakdown ? ( + + ) : ( - } + // fill: match the free card's height in the side-by-side layout and + // bottom-align the button. + fill={sideBySide} + action={sideBySide ? button : undefined} /> - - ) - } - // paidOnly: card + button below, wrapped as one grid cell - return ( - - - + )} + {buttonInBox ? null : button} ) } @@ -127,7 +158,7 @@ const EnrollOfferingBoxes: React.FC = ({ const renderFreeBox = () => { if (!freeAction) return null - if (offering === "both") { + if (sideBySide) { // Button inside card. Secondary (outline) only here, to distinguish it // from the primary Certificate Track button alongside it. return ( @@ -170,8 +201,13 @@ const EnrollOfferingBoxes: React.FC = ({ return ( <> - {offering === "both" && ( - Choose Your Path + {sideBySide && ( + )} {renderPaidBox()} {renderFreeBox()} diff --git a/frontends/main/src/app-pages/ProductPages/FaqsSection.test.tsx b/frontends/main/src/app-pages/ProductPages/FaqsSection.test.tsx new file mode 100644 index 0000000000..3c231ef87a --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/FaqsSection.test.tsx @@ -0,0 +1,117 @@ +import React from "react" +import { factories } from "api/mitxonline-test-utils" +import { renderWithProviders, screen, within, user } from "@/test-utils" +import FaqsSection from "./FaqsSection" + +const makeFaq = factories.pages.faqItem + +test("Renders a labelled region with every question collapsed", async () => { + const faqs = Array.from({ length: 3 }, () => makeFaq()) + renderWithProviders() + + const section = await screen.findByRole("region", { + name: "FAQs", + }) + + faqs.forEach((faq) => { + const button = within(section).getByRole("button", { name: faq.question }) + expect(button).toHaveAttribute("aria-expanded", "false") + // Each question is also a heading so the page keeps a clean outline. + within(section).getByRole("heading", { level: 3, name: faq.question }) + }) +}) + +test("Expands an answer on click and leaves the others closed", async () => { + const faqs = Array.from({ length: 3 }, () => makeFaq()) + renderWithProviders() + + const [first, second] = faqs + await user.click(screen.getByRole("button", { name: first.question })) + + expect(screen.getByRole("button", { name: first.question })).toHaveAttribute( + "aria-expanded", + "true", + ) + expect(screen.getByRole("button", { name: second.question })).toHaveAttribute( + "aria-expanded", + "false", + ) +}) + +test("Renders the answer HTML", async () => { + const faq = makeFaq({ + answer: '

See the docs.

', + }) + renderWithProviders() + + const raws = (await screen.findAllByTestId("raw")).map((el) => el.innerHTML) + expect(raws).toContain(faq.answer) +}) + +test("Emits FAQPage structured data", () => { + const faqs = [makeFaq(), makeFaq()] + const { view } = renderWithProviders() + + const script = view.container.querySelector( + 'script[type="application/ld+json"]', + ) + expect(script).not.toBeNull() + const data = JSON.parse((script?.textContent ?? "").replace(/\\u003c/g, "<")) + expect(data["@type"]).toBe("FAQPage") + expect(data.mainEntity).toHaveLength(2) + expect(data.mainEntity[0].name).toBe(faqs[0].question) +}) + +test("Escapes HTML metacharacters so structured data can't break out of the script", () => { + const faq = makeFaq({ + question: "Is 1 < 2 & 2 > 1?", + answer: '

Yes.

', + }) + const { view } = renderWithProviders() + + const script = view.container.querySelector( + 'script[type="application/ld+json"]', + ) + const raw = script?.textContent ?? "" + // No unescaped angle brackets survive, so an authored can't + // terminate the element early. + expect(raw).not.toContain("<") + expect(raw).not.toContain(">") + // The escaped payload still round-trips back to the original data. + const data = JSON.parse(raw) + expect(data.mainEntity[0].name).toBe(faq.question) + expect(data.mainEntity[0].acceptedAnswer.text).toBe(faq.answer) +}) + +test("Toggles the summary icon between add and subtract", async () => { + const faq = makeFaq() + renderWithProviders() + + const button = screen.getByRole("button", { name: faq.question }) + const iconPath = () => + button + .querySelector(".MuiAccordionSummary-expandIconWrapper svg path") + ?.getAttribute("d") + + const collapsed = iconPath() + await user.click(button) + const expanded = iconPath() + + expect(collapsed).toBeTruthy() + expect(expanded).toBeTruthy() + expect(expanded).not.toBe(collapsed) +}) + +test("Wires aria-controls and aria-labelledby between each summary and its panel", () => { + const faqs = Array.from({ length: 2 }, () => makeFaq()) + renderWithProviders() + + faqs.forEach((faq) => { + const button = screen.getByRole("button", { name: faq.question }) + const panelId = button.getAttribute("aria-controls") + expect(panelId).toBeTruthy() + const panel = document.getElementById(panelId as string) + expect(panel).not.toBeNull() + expect(panel?.getAttribute("aria-labelledby")).toBe(button.id) + }) +}) diff --git a/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx b/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx new file mode 100644 index 0000000000..c370bf5c0e --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx @@ -0,0 +1,144 @@ +"use client" + +import React from "react" +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, +} from "ol-components" +import { styled } from "@mitodl/smoot-design" +import { RiAddLine, RiSubtractLine } from "@remixicon/react" +import type { FAQItem } from "@mitodl/mitxonline-api-axios/v2" +import { HeadingIds } from "./util" +import RawHTML from "./RawHTML" +import { buildFaqStructuredData } from "./faqStructuredData" + +const FaqsSectionRoot = styled.section({ + display: "flex", + flexDirection: "column", + gap: "24px", +}) + +const Items = styled.div({ + display: "flex", + flexDirection: "column", + gap: "8px", +}) + +const FaqItem = styled(Accordion)(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "4px", + boxShadow: "none", + margin: 0, + "&::before": { display: "none" }, + "&:first-of-type, &:last-of-type": { + borderRadius: "4px", + }, + ".MuiAccordion-heading": { + margin: 0, + }, +})) + +const FaqSummary = styled(AccordionSummary)(({ theme }) => ({ + padding: "0 24px", + ".MuiAccordionSummary-content": { + margin: "24px 0", + }, + // Tighten the gap below the question once the answer is showing. + "&.Mui-expanded .MuiAccordionSummary-content": { + marginBottom: "16px", + }, + ".MuiAccordionSummary-expandIconWrapper": { + color: theme.custom.colors.darkGray2, + }, + "&.Mui-focusVisible": { + outline: `2px solid ${theme.custom.colors.red}`, + outlineOffset: "-2px", + backgroundColor: "transparent", + }, + [theme.breakpoints.down("md")]: { + padding: "0 16px", + ".MuiAccordionSummary-content": { + margin: "16px 0", + }, + }, +})) + +const Question = styled.span<{ expanded: boolean }>(({ theme, expanded }) => ({ + ...theme.typography.subtitle1, + color: expanded ? theme.custom.colors.red : theme.custom.colors.darkGray2, + transition: `color ${theme.transitions.duration.shorter}ms`, + ".MuiAccordionSummary-root:hover &": { + color: theme.custom.colors.red, + }, + [theme.breakpoints.down("md")]: { + ...theme.typography.subtitle2, + }, +})) + +const Answer = styled(AccordionDetails)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + padding: "0 24px 24px", + "ul, ol": { + paddingLeft: "12px", + }, + [theme.breakpoints.down("md")]: { + padding: "0 16px 16px", + }, +})) + +const FaqRow: React.FC<{ index: number; faq: FAQItem }> = ({ index, faq }) => { + const [expanded, setExpanded] = React.useState(false) + const headerId = `${HeadingIds.Faqs}-header-${index}` + const panelId = `${HeadingIds.Faqs}-panel-${index}` + + return ( + setExpanded(!expanded)} + disableGutters + > + : } + > + {faq.question} + + + + + + ) +} + +const FaqsSection: React.FC<{ faqs: FAQItem[] }> = ({ faqs }) => { + const structuredData = buildFaqStructuredData(faqs) + + return ( + + + FAQs + + + {faqs.map((faq, index) => ( + + ))} + + {structuredData ? ( + ", "script"), + ('', "onerror"), + ('

text

', "onclick"), + ('click', "javascript:"), + ('', "iframe"), + ('', "onload"), + ], +) +def test_program_letter_template_text_is_sanitized(field, html, unwanted): + """ + Letter text is rendered with dangerouslySetInnerHTML, so it is sanitized + here rather than relying on MicroMasters' Wagtail config staying as it is. + """ + assert unwanted not in serialize_letter_html(field, html) + + +@pytest.mark.parametrize("field", LETTER_HTML_FIELDS) +@pytest.mark.parametrize( + "html", + [ + # Markup live MicroMasters letters actually use. + "

Congratulations on completing the program.

", + "

MASTER OF ENGINEERING
MIT

", + "
  • 14.100x
  • 14.73x
", + # Headings are styled by the letter page's header/footer blocks. + "

Congratulations

", + ], +) +def test_program_letter_template_text_keeps_authored_markup(field, html): + """Sanitizing must not quietly drop the markup editors legitimately use""" + assert serialize_letter_html(field, html) == html + + +@pytest.mark.parametrize("field", LETTER_HTML_FIELDS) +def test_program_letter_template_text_keeps_links(field): + """ + Live letters link out to MIT pages, so href survives -- nh3 adds rel + hardening rather than dropping the anchor. + """ + result = serialize_letter_html(field, 'IDSS') + assert 'href="https://idss.mit.edu"' in result + assert ">IDSS" in result diff --git a/profiles/views_test.py b/profiles/views_test.py index 738fa80b55..42a9591eb0 100644 --- a/profiles/views_test.py +++ b/profiles/views_test.py @@ -528,6 +528,46 @@ def test_program_letter_api_view(mocker, client, rf, user, is_anonymous, setting ) +def test_program_letter_api_view_omits_certificate_pii(mocker, client, user, settings): + """ + The unauthenticated letter endpoint exposes only the name and program the + letter itself states -- not the learner's contact or demographic details. + + Anyone holding the letter's uuid can read this response, so a field added + to ProgramCertificate must not reach it by default. + """ + settings.DATABASE_ROUTERS = [] + mocker.patch( + "profiles.serializers.fetch_program_letter_template_data", + return_value={ + "id": 4, + "meta": {}, + "program_letter_footer": "", + "program_letter_logo": {}, + "title": "Supply Chain Management", + "program_id": 1, + "program_letter_footer_text": "", + "program_letter_header_text": "", + "program_letter_text": "

Congratulations

", + "program_letter_signatories": [], + }, + ) + cert = ProgramCertificateFactory( + user_email=user.email, + micromasters_program_id=1, + user_street_address="77 Massachusetts Ave", + user_year_of_birth="1970", + ) + program_letter = ProgramLetterFactory(user=user, certificate=cert) + + response = client.get( + reverse("profile:v1:program_letters_api-detail", args=[program_letter.id]) + ) + + assert response.status_code == 200 + assert set(response.json()["certificate"]) == {"user_full_name", "program_title"} + + @pytest.mark.parametrize("certificate_id", [None, "no-such-record-hash"]) def test_program_letter_api_view_without_certificate( client, settings, user, certificate_id diff --git a/yarn.lock b/yarn.lock index 434ae7ab68..cd179936a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3631,13 +3631,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:2026.9.16": - version: 2026.9.16 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.9.16" +"@mitodl/mitxonline-api-axios@npm:2026.9.21": + version: 2026.9.21 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.9.21" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/9e0c17cf6a346e57954f04e9a69304bff6d420f0521cfba33b19b74d36b025635db388a4b3f0d7f472092d3bdf09856ba80d9dd502d798459123fdc7ae6e8c7c + checksum: 10/8946e317eaacdfbe5910e4e49b2e9c49d5ac262f062d4a3795f28e78487af6e9d8541bea2b7fb04567cc8d25dd10bba574ba2782b89ff357f261876684fa9efd languageName: node linkType: hard @@ -9570,7 +9570,7 @@ __metadata: dependencies: "@faker-js/faker": "npm:^10.5.0" "@mitodl/mit-learn-api-axios": "npm:2026.8.17" - "@mitodl/mitxonline-api-axios": "npm:2026.9.16" + "@mitodl/mitxonline-api-axios": "npm:2026.9.21" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16918,7 +16918,7 @@ __metadata: "@mitodl/arithmix": "npm:^0.2.5" "@mitodl/course-search-utils": "npm:^3.8.1" "@mitodl/hacksnack": "npm:^0.1.2" - "@mitodl/mitxonline-api-axios": "npm:2026.9.16" + "@mitodl/mitxonline-api-axios": "npm:2026.9.21" "@mitodl/smoot-design": "npm:6.36.0" "@mui/base": "npm:5.0.0-beta.70" "@mui/material": "npm:^6.4.5"