From 35e8a1aedc52f56e7c723dddc50685f88a5dc596 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 21 Sep 2026 19:27:36 -0400 Subject: [PATCH 1/5] feat: show a learner's own program price as an applied-savings breakdown (#3943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: price every caller from user_pricing user_flexible_price only answers whether a learner's financial assistance is approved. user_pricing answers what checkout will charge, carries the old answer field for field, and is the only source for the per-user discounts hq#11787 needs to display. The InfoBox hooks keep their gates and their behaviour: the displayed price still ignores an approved discount, which is surfaced as text. The dashboard enrollment dialog stops recomputing a discounted price in the browser from the aid record alone and shows the quote, so a learner holding an automatic or user-tied discount now sees what they will pay. Its query loses the financial-assistance-form condition for the same reason; without that, the quote would be ignored for most courses. The dialog does not gate on authentication: the quote endpoint rejects anonymous requests, and the dialog only opens from the dashboard, which is already behind an authenticated route. getFlexiblePriceForProduct and priceWithDiscount computed that price and go away with it. upgradeRunUrl goes too: it had no callers and was the last reference to the deprecated serializer's type. Co-Authored-By: Claude Fable 5.1 * feat: quote every purchasable program and derive the applied-savings breakdown Widen the program pricing query so it fires for any signed-in learner on a purchasable program, not just one whose CMS page configures a financial assistance form. A program-child purchase credit exists independently of financial aid, so the aid form was never the right gate; it also conflated the form URL with the product's existence, hence the explicit product guard. The quote now yields `breakdown`: the one discount checkout would apply, split into rows that subtract, with the discount's kind resolved from the quote (`paid-amount-off` names a prior purchase; an id matching the flexible price is financial aid). Nothing renders it yet. `showsRange` collapses the advertised range to a single price once a learner has an approved flexible price. The range exists only to advertise the aid floor, so it is meaningless to someone already quoted against it. The CMS list-price comparison is untouched. The query opts out of the error boundary: the browser client throws 400/401/403, so a stale mitxonline session would otherwise replace the whole program page. Co-Authored-By: Claude Opus 5 * feat: add the applied-savings card 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. It replaces the Certificate Track card rather than decorating it, so it has no title, subtitle or feature bullets — a learner with a real number has no use for the advertised one. Nothing renders it yet. The rows are a description list so each label and amount are announced as a pair, and the deduction's sign is text ("minus") rather than colour alone. The discount's kind decides the sub-label and the info dialog: a credit names the purchase it came from, aid names itself, and anything else gets neither — a sale or a personal code has no rule behind it the learner could act on. TrackCard's shell/body pair becomes an exported CardSurface so both cards share one definition of the shaded surface and its fill behaviour; they stretch to a shared row height in the side-by-side layout and cannot drift apart. ProductSummary's LearnMoreDialog is exported and its href made optional, for a dialog whose description is the whole explanation. Extracting it instead would put UnderlinedLink in a cycle or break AboutSection's import of it. Co-Authored-By: Claude Opus 5 * feat: teach the enroll area to render an applied-savings breakdown EnrollOfferingBoxes takes an optional breakdown and, when it has one, replaces the Certificate Track card with AppliedSavingsCard under a heading row: the heading, the financial-aid indicator right-aligned beside it, and -- for a prior-purchase credit alone -- a line saying the purchase is applied to the full program price. A credit is the one discount the learner did not ask for, so it explains itself in the row; every other discount is left to the deduction row, which already names it. Courses pass nothing and are unaffected. The heading is "Choose Your Path" in the paid-plus-free layout, which already had one, and "Continue with full program" in the paid-only layout, which did not. The paid-only layout puts its heading inside the offering cell rather than emitting a full-row grid child: that layout is data-boxes="2", whose tablet grid is two columns with auto-flow, so a full-row heading would push the card out of the metadata block's row. InfoBoxGrid is untouched. The aid indicator moves onto the heading row only when a breakdown exists, since the card it otherwise lives in is not rendered at all. It cannot be pending there: a breakdown only exists once the same lookup has resolved, so FinancialAidPlaceholder stays behind in the card. FinancialAidLink moves to EnrollAreaParts and takes its text as children, because the heading row says "Financial aid applied" where the card says "Financial aid approved (visible at checkout)". Measured with the app's own font at the widths the offering cell reaches: the heading and the aside share a line at the ~346px desktop sidebar (308px of content in 346px) and the aside wraps to a line of its own at the ~268px tablet cell, where an auto left margin keeps it at the right edge — `space-between` would left-align it once alone on a line. "Continue with full program" stays on one line down to ~180px, so it never breaks mid-phrase at any width the grid produces. Co-Authored-By: Claude Opus 5 * feat: show the program's own quote in its InfoBox ProgramEnrollArea passes the breakdown through, so a learner holding a purchase credit or an approved aid tier sees what checkout will charge instead of the advertised price. It also takes compactPrice from the hook's showsRange rather than recomputing toPriceRange(program), which would keep sizing the price slot for a range the learner no longer sees. No priceBlock suppression is needed: priceBlock reaches the DOM only through CertificateTrackCard, which is not rendered when a breakdown exists. useProgramEnrollment gains upgradeLabel, which relabels the paid action "Upgrade to Full Program". ProgramHeaderEnrollButton does not pass it, so the page-header CTA keeps the offering's own label; it runs its own hook with no pricing input, and making it follow would mean flipping its label after the quote resolves. The new label reaches fireEnrollCta verbatim, which splits nothing: enroll_cta_clicked slices on placement, enrollmentMode, resourceType and readableId, and carries label only for human readability. The existing suites already assert that a learner without a discount sees today's InfoBox — they quote every program at list price — so the new cases cover only the deduction state. Co-Authored-By: Claude Opus 5 * feat: show a course's own quote in its InfoBox too Financial aid applies to a course exactly as it does to a program, so the breakdown reads the same on all three product pages. Only the purchase credit stays program-only: it is earned by buying one of the program's own courses and is worded throughout as an upgrade to the full program, which neither a course page nor a program presented as a course can say. `useCourseCertificatePrice` now derives the breakdown from the quote it already fetched, through the same helper the program hook uses, and its query is no longer gated on the CMS financial-assistance form — an automatic or user-tied discount prices a course that offers no aid at all. The label that differs stays with the caller: the card takes the product noun and names its first row "Course price" or "Program price". Co-Authored-By: Claude Opus 5 * refactor: decide each InfoBox pricing rule in one place The paid offering box had four independent rules — a quote with a deduction, an aid form, a purchase credit, an approved aid tier — and several of them were decided in more than one file, or for one product type only. - The credit is decided once, in ProgramEnrollArea, which feeds the upgrade label, the heading and the note from a single boolean. EnrollOfferingBoxes takes plain paidHeading/paidNote props and no longer reads breakdown.kind; CourseEnrollArea passes neither. - The aid indicator is one component, FinancialAidIndicator, with one wording in both homes. "Financial aid approved (visible at checkout)" could only render when nothing was visible at checkout: any approved tier that lowers the price produces a breakdown, which replaces the card that carried it. - The course and program price hooks shared everything but how they found the product, so useCertificatePricing now owns the query, the aid object, the breakdown and the range collapse. The collapse for approved learners thereby applies to courses too, where it had been program-only. useCourseCertificatePrice is deleted. - OfferingHeadingRow returns null when it has nothing to carry; an empty row was still a flex item and opened the cell's 16px gap above the box for a paid-only sale with no aid form. - The (i) button gets aria-haspopup, aria-expanded and a name distinct from the row it explains; the popover gets an accessible name, which needed ol-components' Popover to accept aria-label/aria-labelledby. - Two redundant tests dropped; one added for the empty heading row and one for a quote whose discount takes nothing off, both mutation-checked. Co-Authored-By: Claude Fable 5.1 * If the user's income declaration is approved, but they don't qualify for financial aid, don't show them anything * fix: stop caching a learner's price quote as if it were ETL content The browser client defaults staleTime to the CDN TTL, justified for content that changes on the ETL's cadence. A user_pricing quote is not that: a program-child-purchase credit appears the moment the learner buys a child course. Nothing invalidates productsKeys, so the stale quote was served until the window elapsed. Matches baskets/queries.ts and useComplianceGate. Co-Authored-By: Claude Opus 5 (1M context) * docs: cite the constraint that actually makes paid-amount-off a credit The comment justified the classification by where a program-child-purchase discount attaches, but the code keys on discount_type. What rules out any other pairing is mitxonline's paid_amount_off_discount_shape CheckConstraint, so point at that instead. Co-Authored-By: Claude Opus 5 (1M context) * test: rename the dashboard pricing helper to setupCoursePricing It shared a name with ProductPages/test-utils/userPricing.ts's setupUserPricing while taking different arguments and meaning something different, so the two read as one helper. Co-Authored-By: Claude Opus 5 (1M context) * fix: open the applied-savings popover above the total it explains Popper's default placement put it over "Today's price" and its amount at every width, covering the one row the learner is reading. It still flips down when there is no room above. Co-Authored-By: Claude Opus 5 (1M context) * test: cover the course noun on AppliedSavingsCard productNoun="course" was only exercised indirectly through the enroll-area suites, so FULL_PRICE_LABEL.course had no direct coverage. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Carey P Gumaer --- .../src/mitxonline/hooks/products/queries.ts | 31 ++- .../test-utils/factories/products.ts | 67 ++++- .../api/src/mitxonline/test-utils/urls.ts | 4 +- .../ProgramAsCourseCard.test.tsx | 3 +- .../ProgramEnrollmentDisplay.test.tsx | 3 +- .../UnenrolledCourseCard.compliance.test.tsx | 3 + .../UnenrolledCourseCard.test.tsx | 14 +- .../CoursewareDisplay/test-utils.ts | 31 +++ .../ProductPages/AppliedSavingsCard.test.tsx | 87 ++++++ .../ProductPages/AppliedSavingsCard.tsx | 236 ++++++++++++++++ .../CertificateTrackCard.test.tsx | 4 +- .../ProductPages/CertificateTrackCard.tsx | 47 +--- .../ProductPages/CourseEnrollArea.test.tsx | 168 +++++++++--- .../ProductPages/CourseEnrollArea.tsx | 15 +- .../ProductPages/CoursePage.test.tsx | 2 +- .../ProductPages/EnrollAreaParts.tsx | 115 +++++++- .../ProductPages/EnrollOfferingBoxes.tsx | 118 +++++--- .../ProductPages/InfoBoxCourse.test.tsx | 2 +- .../ProductPages/InfoBoxProgram.test.tsx | 2 +- .../InfoBoxProgramAsCourse.test.tsx | 6 +- .../ProductPages/ProgramEnrollArea.test.tsx | 254 +++++++++++++++++- .../ProductPages/ProgramEnrollArea.tsx | 40 ++- .../src/app-pages/ProductPages/TrackCard.tsx | 67 +++-- .../app-pages/ProductPages/appliedSavings.ts | 56 ++++ .../src/app-pages/ProductPages/enrollTypes.ts | 16 ++ .../ProductPages/test-utils/userPricing.ts | 72 +++++ .../ProductPages/useCertificatePricing.ts | 131 +++++++++ .../ProductPages/useCourseCertificatePrice.ts | 64 ----- .../useProgramCertificatePrice.test.tsx | 223 ++++++++++++++- .../useProgramCertificatePrice.ts | 90 ++----- .../ProductPages/useProgramEnrollment.ts | 11 +- frontends/main/src/common/mitxonline.test.ts | 193 +------------ frontends/main/src/common/mitxonline/index.ts | 66 ----- .../CourseEnrollmentDialog.test.tsx | 131 +++++---- .../CourseEnrollmentDialog.tsx | 58 +++- .../src/components/Popover/Popover.tsx | 9 +- 36 files changed, 1774 insertions(+), 665 deletions(-) create mode 100644 frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.test.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/AppliedSavingsCard.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/appliedSavings.ts create mode 100644 frontends/main/src/app-pages/ProductPages/test-utils/userPricing.ts create mode 100644 frontends/main/src/app-pages/ProductPages/useCertificatePricing.ts delete mode 100644 frontends/main/src/app-pages/ProductPages/useCourseCertificatePrice.ts 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/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/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( { ], } + 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/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index 3d32942f25..8d7a9f539e 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 @@ -616,6 +646,7 @@ const buildProgramScenario = ( } export { + setupCoursePricing, dashboardCourse, dashboardProgram, setupOrderHistory, 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..85759c3afb 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) => { 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/InfoBoxCourse.test.tsx b/frontends/main/src/app-pages/ProductPages/InfoBoxCourse.test.tsx index fe6962e0cf..0b80555770 100644 --- a/frontends/main/src/app-pages/ProductPages/InfoBoxCourse.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/InfoBoxCourse.test.tsx @@ -12,6 +12,7 @@ import { factories as mitxFactories, urls as mitxUrls, } from "api/mitxonline-test-utils" +import { makeCourse } from "./test-utils/userPricing" import InfoBoxCourse from "./InfoBoxCourse" import { formatDate } from "ol-utilities" @@ -30,7 +31,6 @@ 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 diff --git a/frontends/main/src/app-pages/ProductPages/InfoBoxProgram.test.tsx b/frontends/main/src/app-pages/ProductPages/InfoBoxProgram.test.tsx index ccb3e0fa42..23591e6ff0 100644 --- a/frontends/main/src/app-pages/ProductPages/InfoBoxProgram.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/InfoBoxProgram.test.tsx @@ -12,6 +12,7 @@ import { } from "api/mitxonline-test-utils" import InfoBoxProgram from "./InfoBoxProgram" import { TestIds } from "./ProductSummary" +import { makeProgram } from "./test-utils/userPricing" jest.mock("next-nprogress-bar", () => ({ useRouter: () => ({ push: jest.fn() }), @@ -26,7 +27,6 @@ jest.mock("@/common/analytics/gtm", () => ({ trackProgramEnrolled: jest.fn(), })) -const makeProgram = mitxFactories.programs.program const makeMode = mitxFactories.courses.enrollmentMode const makeProduct = mitxFactories.courses.product const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV3 diff --git a/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.test.tsx b/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.test.tsx index 909dd5851c..78d10d534e 100644 --- a/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.test.tsx @@ -6,6 +6,7 @@ import { urls as mitxUrls, } from "api/mitxonline-test-utils" import InfoBoxProgramAsCourse from "./InfoBoxProgramAsCourse" +import { makeProgram } from "./test-utils/userPricing" jest.mock("next-nprogress-bar", () => ({ useRouter: () => ({ push: jest.fn() }), @@ -20,7 +21,6 @@ jest.mock("@/common/analytics/gtm", () => ({ trackProgramEnrolled: jest.fn(), })) -const makeProgram = mitxFactories.programs.program const makeBaseProgram = mitxFactories.programs.baseProgram const makeMode = mitxFactories.courses.enrollmentMode const makeProduct = mitxFactories.courses.product @@ -82,8 +82,8 @@ describe("InfoBoxProgramAsCourse — financial assistance", () => { }, }) setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - mitxFactories.products.flexiblePrice({ product_flexible_price: null }), + mitxUrls.products.userPricingDetail(product.id), + mitxFactories.products.userPricing(), ) renderWithProviders() diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.test.tsx index edb2bba2b9..88590f6f56 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.test.tsx @@ -6,14 +6,19 @@ import { waitFor, setMockResponse, } from "@/test-utils" -import { factories, urls } from "api/test-utils" +import { factories, makeRequest, urls } from "api/test-utils" import { factories as mitxFactories, urls as mitxUrls, } from "api/mitxonline-test-utils" +import { + DiscountTypeEnum, + PaymentTypeEnum, +} from "@mitodl/mitxonline-api-axios/v2" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import { programView } from "@/common/urls" import ProgramEnrollArea from "./ProgramEnrollArea" +import { makeProgram, setupUserPricing } from "./test-utils/userPricing" jest.mock("next-nprogress-bar", () => ({ useRouter: () => ({ push: jest.fn() }), @@ -28,10 +33,10 @@ jest.mock("@/common/analytics/gtm", () => ({ trackProgramEnrolled: jest.fn(), })) -const makeProgram = mitxFactories.programs.program const makeMode = mitxFactories.courses.enrollmentMode const makeProduct = mitxFactories.courses.product -const makeFlexiblePrice = mitxFactories.products.flexiblePrice +const makeUserPricingDiscount = mitxFactories.products.userPricingDiscount +const makeFlexiblePrice = mitxFactories.products.discount const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV3 const makeUser = factories.user.user @@ -219,19 +224,14 @@ describe("ProgramEnrollArea — savings", () => { describe("ProgramEnrollArea — financial assistance link", () => { test("(f) shows the financial assistance link inside the Certificate Track card", async () => { setupAuth() - const product = makeProduct({ price: "800" }) const program = makeProgram({ enrollment_modes: [makeMode({ requires_payment: true })], - products: [product], + products: [makeProduct({ price: "800" })], page: { list_price: "800", financial_assistance_form_url: "/financial-aid/foo", }, }) - setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - makeFlexiblePrice({ product_flexible_price: null }), - ) renderWithProviders() @@ -247,6 +247,242 @@ describe("ProgramEnrollArea — financial assistance link", () => { }) }) +describe("ProgramEnrollArea — applied savings", () => { + test("(h) a quoted discount replaces the Certificate Track card under Choose Your Path", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [ + makeMode({ requires_payment: false }), + makeMode({ requires_payment: true }), + ], + products: [makeProduct({ price: "899" })], + }) + const discount = makeUserPricingDiscount({ amount_off: "300" }) + setupUserPricing(program, { user_price: "599", discount }) + + renderWithProviders() + + const upgrade = await screen.findByRole("button", { + name: "Upgrade to Full Program", + }) + const headingRow = screen.getByText("Choose Your Path") + .parentElement as HTMLElement + expect(headingRow).toHaveTextContent( + "Your purchase is applied to the full program price.", + ) + + const certCell = document.querySelector("[data-card='cert']") as HTMLElement + within(certCell).getByText(discount.source!.title) + within(certCell).getByText("$599") + within(certCell).getByRole("button", { name: "Upgrade to Full Program" }) + expect( + screen.queryByRole("heading", { name: "Certificate Track" }), + ).toBeNull() + expect(upgrade.closest("[data-size]")).toHaveAttribute( + "data-size", + "medium", + ) + + // The free path is untouched, label included. + screen.getByRole("heading", { name: "Learn for Free", level: 3 }) + screen.getByRole("button", { name: "Start Learning" }) + }) + + test("(i) paid-only gains a heading, takes the button inside the box, and drops the savings block", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "800" })], + page: { list_price: "1000" }, + }) + setupUserPricing(program, { + user_price: "650", + discount: makeUserPricingDiscount({ amount_off: "150" }), + }) + + renderWithProviders() + + const upgrade = await screen.findByRole("button", { + name: "Upgrade to Full Program", + }) + const certCell = document.querySelector("[data-card='cert']") as HTMLElement + within(certCell).getByText("Continue with full program") + within(certCell).getByText("$650") + expect(upgrade.closest("[data-size]")).toHaveAttribute( + "data-size", + "medium", + ) + expect(screen.queryByText("Save $200")).toBeNull() + }) + + test("(i) paid-only aid carries no heading text and no note, keeping the row for its indicator", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "899" })], + page: { financial_assistance_form_url: "/financial-aid/foo" }, + }) + setupUserPricing(program, { + user_price: "399", + discount: makeUserPricingDiscount({ + amount_off: "500", + // The factory defaults to paid-amount-off, which reads as a credit. + discount_type: DiscountTypeEnum.DollarsOff, + payment_type: PaymentTypeEnum.FinancialAssistance, + }), + product_flexible_price: makeFlexiblePrice(), + }) + + renderWithProviders() + + // Only a credit names the row, and only a credit explains itself beneath + // it; aid leaves the heading, the note and the action alike in the + // offering's own words. + await screen.findByRole("button", { name: "Enroll in Program" }) + expect(screen.queryByText("Continue with full program")).toBeNull() + screen.getByRole("link", { name: "Financial aid approved" }) + expect( + screen.queryByText(/is applied to the full program price/), + ).toBeNull() + screen.getByText("Program price") + }) + + test("(i) paid-only with nothing for the heading row renders no row above the box", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "899" })], + }) + // A sale: no heading, no note, and no aid form for the indicator. + setupUserPricing(program, { + user_price: "849", + discount: makeUserPricingDiscount({ + amount_off: "50", + discount_type: DiscountTypeEnum.DollarsOff, + source: null, + }), + }) + + renderWithProviders() + + await screen.findByRole("button", { name: "Enroll in Program" }) + // An empty row would still be a flex item and open the cell's gap above + // the box, so the box has to be the cell's first child. + const certCell = document.querySelector("[data-card='cert']") as HTMLElement + expect(certCell.firstElementChild).toHaveTextContent("Program price") + }) + + test("(i) displayAsCourse keeps an aid breakdown, priced as a course", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "899" })], + page: { financial_assistance_form_url: "/financial-aid/foo" }, + }) + setupUserPricing(program, { + user_price: "399", + discount: makeUserPricingDiscount({ + amount_off: "500", + // The factory defaults to paid-amount-off, which reads as a credit. + discount_type: DiscountTypeEnum.DollarsOff, + payment_type: PaymentTypeEnum.FinancialAssistance, + }), + product_flexible_price: makeFlexiblePrice(), + }) + + renderWithProviders() + + await screen.findByRole("button", { name: "Enroll" }) + screen.getByText("Course price") + expect(screen.queryByText("Program price")).toBeNull() + screen.getByText("$399") + }) + + test("(i) displayAsCourse drops a credit, which has no course wording", async () => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "800" })], + }) + setupUserPricing(program, { + user_price: "650", + discount: makeUserPricingDiscount({ + amount_off: "150", + discount_type: DiscountTypeEnum.PaidAmountOff, + }), + }) + + renderWithProviders() + + await screen.findByRole("button", { name: "Enroll" }) + expect(screen.queryByText("Course price")).toBeNull() + expect(screen.queryByText("Continue with full program")).toBeNull() + screen.getByText("$800") + }) + + test.each([ + { approved: false, indicator: "Apply for financial aid" }, + { approved: true, indicator: "Financial aid approved" }, + ])( + "(j) the aid indicator moves onto the heading row, reading $indicator", + async ({ approved, indicator }) => { + setupAuth() + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [makeProduct({ price: "899" })], + page: { financial_assistance_form_url: "/financial-aid/foo" }, + }) + setupUserPricing(program, { + user_price: "599", + discount: makeUserPricingDiscount({ amount_off: "300" }), + product_flexible_price: approved ? makeFlexiblePrice() : null, + }) + + renderWithProviders() + + await screen.findByRole("button", { name: "Upgrade to Full Program" }) + const headingRow = screen.getByText("Continue with full program") + .parentElement as HTMLElement + within(headingRow).getByRole("link", { name: indicator }) + expect( + screen.queryByRole("heading", { name: "Certificate Track" }), + ).toBeNull() + }, + ) +}) + +describe("ProgramEnrollArea — a quote that fails", () => { + test("(k) the ordinary card stays, and the page survives", async () => { + setupAuth() + const product = makeProduct({ price: "800" }) + const program = makeProgram({ + enrollment_modes: [makeMode({ requires_payment: true })], + products: [product], + }) + // A stale mitxonline session. The browser query client throws a 401 into + // the route's error boundary unless the query opts out, which would replace + // the whole program page — so rendering at all is half the assertion. + setMockResponse.get( + mitxUrls.products.userPricingDetail(product.id), + {}, + { code: 401 }, + ) + + renderWithProviders() + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: mitxUrls.products.userPricingDetail(product.id), + }), + ), + ) + await screen.findByRole("button", { name: "Enroll in Program" }) + screen.getByRole("heading", { name: "Certificate Track", level: 3 }) + screen.getByText("$800") + }) +}) + describe("ProgramEnrollArea — none scenario", () => { test("(g) paid-only without a purchasable product renders nothing", async () => { setupAuth() diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.tsx index b6658aa84c..1bf405580d 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollArea.tsx @@ -1,11 +1,24 @@ import React from "react" import type { V2ProgramDetail } from "@mitodl/mitxonline-api-axios/v2" -import { toPriceRange } from "@/common/mitxonline" import { useProgramEnrollment } from "./useProgramEnrollment" import { useProgramCertificatePrice } from "./useProgramCertificatePrice" import ProgramSavingsBlock from "./ProgramSavingsBlock" import EnrollOfferingBoxes from "./EnrollOfferingBoxes" +/** + * A credit arrives unasked — it is earned by buying one of the program's own + * courses, not applied for — so the row explains itself here rather than only + * behind the popover, and "full" is the point of the sentence. Every other + * discount goes without: the learner either asked for it, as with financial + * aid, or it is a sale or a personal code that needs no explaining, and in each + * case the deduction row already names it. + */ +const CREDIT_NOTE = ( + <> + Your purchase is applied to the full program price. + +) + type ProgramEnrollAreaProps = { program: V2ProgramDetail displayAsCourse?: boolean @@ -17,20 +30,34 @@ const ProgramEnrollArea: React.FC = ({ }) => { const [anchor, setAnchor] = React.useState(null) + const { price, showsRange, savings, financialAid, breakdown } = + useProgramCertificatePrice(program) + + // A credit is earned by buying one of the program's own courses and is worded + // throughout as an upgrade to the full program, which a page presenting that + // program as a single course cannot say. Every other discount — financial aid + // above all — applies just as much here as on the program page, and shows. + const appliedSavings = + displayAsCourse && breakdown?.kind === "credit" ? null : breakdown + + // The credit is the one discount that changes the offering's own words: it + // relabels the action, heads the paid box, and explains itself beneath the + // heading. Aid and every other discount leave all three alone. Deciding the + // three here, together, is what keeps them from drifting apart. + const credit = appliedSavings?.kind === "credit" + const { state, offering, isStatusLoading, isPending, isError } = useProgramEnrollment(program, { tracking: { placement: "infobox" }, displayAsCourse, + upgradeLabel: credit, onRequireSignup: setAnchor, }) - const { price, savings, financialAid } = useProgramCertificatePrice(program) // Savings framing is full-program-page presentation only; program-as-course // shows the plain price even when a list price is set. const priceBlock = !displayAsCourse && savings ? : null - // Same predicate the hook formats on, so the sizing tracks the display. - const isRange = toPriceRange(program) !== null return ( = ({ isPending={isPending} isError={isError} price={price} - compactPrice={isRange} + compactPrice={showsRange} priceBlock={priceBlock} financialAid={financialAid} + breakdown={appliedSavings} + paidHeading={credit ? "Continue with full program" : undefined} + paidNote={credit ? CREDIT_NOTE : undefined} productNoun={displayAsCourse ? "course" : "program"} anchor={anchor} onAnchorClose={() => setAnchor(null)} diff --git a/frontends/main/src/app-pages/ProductPages/TrackCard.tsx b/frontends/main/src/app-pages/ProductPages/TrackCard.tsx index 6f4b7d4903..1f9a59f27f 100644 --- a/frontends/main/src/app-pages/ProductPages/TrackCard.tsx +++ b/frontends/main/src/app-pages/ProductPages/TrackCard.tsx @@ -6,7 +6,9 @@ import { RiCheckLine } from "@remixicon/react" * Shared scaffold for the two enrollment "track" cards (Certificate Track and * Learn for Free), which differ only in surface treatment and content. Keeping * the structure here means both cards share the exact DOM the grid layout - * stretches, so they can't drift apart. + * stretches, so they can't drift apart. `CardSurface` is that DOM without the + * card's header and body, for a box that sits in the same row but is not a + * track card. * * `fill` makes the card grow to fill a stretched grid cell and drops its action * to the bottom — used in the side-by-side "both" layout so the two cards match @@ -53,6 +55,23 @@ const CardBody = styled.div<{ $variant: CardVariant; $fill?: boolean }>( }), ) +/** + * The card's outer shape: shaded or bordered body, and the `fill` behaviour that + * makes it grow to a stretched grid cell with its last child pinned to the + * bottom. Shared so cards sitting in the same grid row cannot drift apart. + */ +export const CardSurface: React.FC<{ + variant: CardVariant + fill?: boolean + children: React.ReactNode +}> = ({ variant, fill, children }) => ( + + + {children} + + +) + const CardHeader = styled.div<{ $hasAside?: boolean }>(({ $hasAside }) => ({ display: "flex", flexDirection: "column", @@ -211,30 +230,28 @@ const TrackCard: React.FC = ({ fill, }) => { return ( - - - - - - {title} - {priceBlock ? null : ( - {price} - )} - - {headerAside} - - {subtitle} - - - {priceBlock ? {priceBlock} : null} - - {note} - - {children} - - {action} - - + + + + + {title} + {priceBlock ? null : ( + {price} + )} + + {headerAside} + + {subtitle} + + + {priceBlock ? {priceBlock} : null} + + {note} + + {children} + + {action} + ) } diff --git a/frontends/main/src/app-pages/ProductPages/appliedSavings.ts b/frontends/main/src/app-pages/ProductPages/appliedSavings.ts new file mode 100644 index 0000000000..0a8f2627e2 --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/appliedSavings.ts @@ -0,0 +1,56 @@ +import { + DiscountTypeEnum, + PaymentTypeEnum, +} from "@mitodl/mitxonline-api-axios/v2" +import type { UserPricingProduct } from "@mitodl/mitxonline-api-axios/v2" +import { formatPrice } from "@/common/mitxonline" +import type { AppliedSavings } from "./enrollTypes" + +const toNumericPrice = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +/** + * The rows a quote subtracts into, or null when it takes nothing off. Shared by + * the course and program price hooks: the quote answers the same question for + * either product, and the wording that differs between them belongs to the + * views, not here. + * + * `kind` distinguishes the two discounts with a rule behind them. "credit" keys + * on the discount type rather than the redemption type, which holds because + * mitxonline's `paid_amount_off_discount_shape` CheckConstraint makes + * paid-amount-off imply the program-child-purchase redemption type — the + * constraint, not the product-link rule, is what rules out any other pairing. + * A course quote therefore reaches "aid" or "other" and never "credit": a + * paid-amount-off discount is only ever funded by resolving a program's + * requirement tree, and `_program_for_product` returns None for a product that + * does not sell a program, so no source resolves and no amount arrives. + */ +export const toAppliedSavings = ( + quote: UserPricingProduct | undefined, +): AppliedSavings | null => { + const discount = quote?.discount + const amountOff = discount ? toNumericPrice(discount.amount_off) : null + // A discount worth nothing cannot reach us — the backend keeps a candidate + // only while it beats the running price — so this guard is defensive. + if (!quote || !discount || amountOff === null || amountOff <= 0) return null + return { + fullPrice: formatPrice(quote.price), + amountOff: formatPrice(amountOff), + todaysPrice: formatPrice(quote.user_price), + sourceTitle: discount.source?.title ?? null, + kind: + discount.discount_type === DiscountTypeEnum.PaidAmountOff + ? "credit" + : discount.payment_type === PaymentTypeEnum.FinancialAssistance + ? "aid" + : "other", + } +} + +export { toNumericPrice } diff --git a/frontends/main/src/app-pages/ProductPages/enrollTypes.ts b/frontends/main/src/app-pages/ProductPages/enrollTypes.ts index cceb19e1de..62bf402531 100644 --- a/frontends/main/src/app-pages/ProductPages/enrollTypes.ts +++ b/frontends/main/src/app-pages/ProductPages/enrollTypes.ts @@ -29,6 +29,22 @@ export type FinancialAid = { pending: boolean } +/** + * The one discount checkout will apply for this learner, broken into rows that + * subtract. Every amount comes from the same quote, so they cannot disagree. + */ +export type AppliedSavings = { + /** Row 1: the product's price. The view supplies the noun that labels it. */ + fullPrice: string + /** Row 2, unsigned — the view renders the sign. */ + amountOff: string + /** Row 3: what checkout charges. */ + todaysPrice: string + /** The credited purchase, when the discount names one. */ + sourceTitle: string | null + kind: "credit" | "aid" | "other" +} + /** * What a product lets you enroll in right now — the ACTIONABLE offering, not * the raw enrollment modes (a paid path additionally requires a purchasable diff --git a/frontends/main/src/app-pages/ProductPages/test-utils/userPricing.ts b/frontends/main/src/app-pages/ProductPages/test-utils/userPricing.ts new file mode 100644 index 0000000000..fa04cb273d --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/test-utils/userPricing.ts @@ -0,0 +1,72 @@ +import type { + CourseRunV2, + UserPricingProduct, + V2ProgramDetail, +} from "@mitodl/mitxonline-api-axios/v2" +import { urls, factories } from "api/mitxonline-test-utils" +import { setMockResponse } from "@/test-utils" + +/** + * Quote every purchasable product on a program. Defaults to list price with no + * discount, which is the no-change case; pass `quote` to give the learner a + * discount, an approved flexible price, or a different price to pay. + */ +const setupUserPricing = ( + program: V2ProgramDetail, + quote: Partial = {}, +): void => { + program.products.forEach((product) => + setMockResponse.get( + urls.products.userPricingDetail(product.id), + factories.products.userPricing({ + id: product.id, + price: product.price, + ...quote, + }), + ), + ) +} + +/** + * A program, with a list-price quote registered for each of its products. The + * certificate card quotes every signed-in learner, so a program built without + * one fails any test that renders it authenticated. + */ +const makeProgram: typeof factories.programs.program = (overrides) => { + const program = factories.programs.program(overrides) + setupUserPricing(program) + return program +} + +/** + * Quote every purchasable product on a course run. The course counterpart of + * `setupUserPricing`; a run's products are what a course page prices. + */ +const setupRunPricing = ( + run: CourseRunV2, + quote: Partial = {}, +): void => { + run.products?.forEach((product) => + setMockResponse.get( + urls.products.userPricingDetail(product.id), + factories.products.userPricing({ + id: product.id, + price: product.price, + ...quote, + }), + ), + ) +} + +/** + * A course, with a list-price quote registered for each of its runs' products. + * The certificate card quotes every signed-in learner, so a course built + * without one fails any test that renders it authenticated. + */ +const makeCourse: typeof factories.courses.course = (overrides) => { + const course = factories.courses.course(overrides) + course.courseruns?.forEach((run) => setupRunPricing(run)) + return course +} + +export { makeCourse, makeProgram, setupRunPricing, setupUserPricing } diff --git a/frontends/main/src/app-pages/ProductPages/useCertificatePricing.ts b/frontends/main/src/app-pages/ProductPages/useCertificatePricing.ts new file mode 100644 index 0000000000..9d42419526 --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/useCertificatePricing.ts @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query" +import type { BaseProduct } from "@mitodl/mitxonline-api-axios/v2" +import { productQueries } from "api/mitxonline-hooks/products" +import { useUserIsAuthenticated } from "api/hooks/user" +import { + formatPrice, + formatResourcePrice, + mitxonlineLegacyUrl, + toPriceRange, +} from "@/common/mitxonline" +import type { AppliedSavings, FinancialAid } from "./enrollTypes" +import { toAppliedSavings } from "./appliedSavings" + +/** The parts of a course or program that price its certificate track. */ +type PricedResource = { + page?: { financial_assistance_form_url?: string | null } | null + min_price?: number | null + max_price?: number | null +} + +type CertificatePricing = { + /** + * Formatted price — the advertised range when the resource has one and this + * learner can still reach its floor, else the full product price. Null when + * there is neither. + */ + price: string | null + /** + * Whether `price` is an advertised range. Callers size the price slot from + * this; deriving it from the resource alone would keep a range's width for a + * learner whose price has collapsed to a single number. + */ + showsRange: boolean + /** Whether this learner has an approved flexible price, at any tier. */ + approvedForAid: boolean + /** + * The aid indicator to render, or null when there is nothing true to say — + * no aid on offer, or an approval that discounted nothing. See the hook. + */ + financialAid: FinancialAid | null + /** Present only when the quote takes something off; see AppliedSavings. */ + breakdown: AppliedSavings | null +} + +/** + * Price facts shared by the course and program Certificate Track cards: the + * price to display, the discount checkout would apply, and financial aid info. + * The two differ only in how they find the product and in whether they are + * purchasable, so the caller supplies both and everything downstream of the + * quote is decided once, here. + * + * The advertised range exists to advertise the financial-assistance floor, so a + * learner who has already been quoted against it — anyone with an approved + * flexible price — is shown a single price instead. + */ +export const useCertificatePricing = ( + resource: PricedResource, + product: BaseProduct | undefined, + { purchasable }: { purchasable: boolean }, +): CertificatePricing => { + const isAuthenticated = useUserIsAuthenticated() + const financialAidUrl = resource.page?.financial_assistance_form_url + const hasFinancialAid = !!(financialAidUrl && product) + + // The pricing lookup is user-scoped and the endpoint rejects anonymous + // requests; never fire it for a visitor who is not signed in. + // + // Any purchasable product can carry a discount, whether or not it offers + // financial assistance, so the guard is the product rather than the CMS aid + // form: a learner can hold an automatic or user-tied discount either way. + // Without a product the query would ask for product 0. + const userPricing = useQuery({ + ...productQueries.userPricingDetail({ productId: product?.id ?? 0 }), + enabled: isAuthenticated && purchasable && !!product?.price, + // The browser query client throws 400/401/403 into the route's error + // boundary, which would replace the whole page over a stale mitxonline + // session. A quote we cannot get just leaves the card as it is. + throwOnError: false, + }) + + const quote = userPricing.data + // Whether aid is approved, and so a tier has already been quoted. Not the + // same question as whether aid is the discount that won: a learner can be + // approved and still lose the slot to a cheaper discount. + const approvedForAid = !!quote?.product_flexible_price + const breakdown = toAppliedSavings(quote) + + // mitxonline's APPROVED means it accepted the learner's declared income, not + // that the income earned them anything: the top tier discounts by 0%, and the + // aid form tells that learner "You did not qualify for financial assistance." + // Approved with nothing off is therefore the one state this row cannot word. + // "Approved" beside an undiscounted price claims a success they did not get, + // and "Apply" is wrong because they already did. So it says nothing, and the + // rejection stays on the aid form where they read it, rather than being + // repeated beside a buy button. Approved-but-outbid keeps the indicator: that + // learner has a real tier and a real discount, just not this one. + const aidHasNothingToSay = approvedForAid && !breakdown + + const financialAid = + hasFinancialAid && !aidHasNothingToSay + ? { + href: mitxonlineLegacyUrl(financialAidUrl), + applied: approvedForAid, + // isLoading, not isPending: a disabled query stays pending forever, and + // this one is disabled for anonymous visitors, who are never approved + // and so have nothing to wait for. + pending: userPricing.isLoading, + } + : null + + const showsRange = toPriceRange(resource) !== null && !approvedForAid + + // An advertised range displays even with no purchasable product, so the + // InfoBox agrees with MitxOnlineResourceCard for the same resource. + // formatResourcePrice prefers the range; a learner already quoted against it + // gets the single product price instead. + const price = + !showsRange && product?.price + ? formatPrice(product.price) + : formatResourcePrice(resource, product?.price || null) + + return { + price, + showsRange, + approvedForAid, + financialAid, + breakdown, + } +} + +export type { CertificatePricing } diff --git a/frontends/main/src/app-pages/ProductPages/useCourseCertificatePrice.ts b/frontends/main/src/app-pages/ProductPages/useCourseCertificatePrice.ts deleted file mode 100644 index ad5485d4d8..0000000000 --- a/frontends/main/src/app-pages/ProductPages/useCourseCertificatePrice.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useQuery } from "@tanstack/react-query" -import type { - CourseRunV2, - CourseWithCourseRunsSerializerV2, -} from "@mitodl/mitxonline-api-axios/v2" -import { productQueries } from "api/mitxonline-hooks/products" -import { useUserIsAuthenticated } from "api/hooks/user" -import { - canPurchaseRun, - mitxonlineLegacyUrl, - formatResourcePrice, -} from "@/common/mitxonline" -import type { FinancialAid } from "./enrollTypes" - -type CourseCertificatePriceResult = { - price: string | null - financialAid: FinancialAid | null -} - -/** - * Returns the price and financial aid info for a course's Certificate Track - * card. The displayed price is the course's advertised range when it has one, - * otherwise the run product's full price. A financial aid discount is never - * reflected in it — not even for a user whose flexible price is already - * approved — because the discount is applied later, in checkout; it is surfaced - * as a text note instead. `applied` reports whether the user has an approved - * flexible price. - */ -export const useCourseCertificatePrice = ( - course: CourseWithCourseRunsSerializerV2, - selectedRun: CourseRunV2 | undefined, -): CourseCertificatePriceResult => { - const isAuthenticated = useUserIsAuthenticated() - const product = selectedRun?.products?.[0] - const financialAidUrl = course?.page?.financial_assistance_form_url - const hasFinancialAid = !!(financialAidUrl && product) - const canPurchase = selectedRun ? canPurchaseRun(selectedRun) : false - - // The flexible-price lookup is user-scoped; never fire it for anonymous - // visitors. - const userFlexiblePrice = useQuery({ - ...productQueries.userFlexiblePriceDetail({ productId: product?.id ?? 0 }), - enabled: isAuthenticated && canPurchase && hasFinancialAid, - }) - - const financialAid = hasFinancialAid - ? { - href: mitxonlineLegacyUrl(financialAidUrl), - applied: !!userFlexiblePrice.data?.product_flexible_price?.id, - // isLoading, not isPending: a disabled query stays pending forever, and - // this one is disabled for anonymous visitors, who are never approved - // and so have nothing to wait for. - pending: userFlexiblePrice.isLoading, - } - : null - - // An advertised range displays even with no purchasable product, so the - // InfoBox agrees with MitxOnlineResourceCard for the same resource; without - // either a range or a product price there is nothing to show. - return { - price: formatResourcePrice(course, product?.price || null), - financialAid, - } -} diff --git a/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.test.tsx b/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.test.tsx index 5a1e97f0a8..ce66ecc851 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.test.tsx @@ -15,7 +15,8 @@ import { useProgramCertificatePrice } from "./useProgramCertificatePrice" const programs = factories.programs const courses = factories.courses -const makeFlexiblePrice = factories.products.flexiblePrice +const makeUserPricing = factories.products.userPricing +const makeUserPricingDiscount = factories.products.userPricingDiscount const makeDiscount = factories.products.discount const makeUser = apiFactories.user.user @@ -29,7 +30,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => { describe("useProgramCertificatePrice", () => { beforeEach(() => { // Most cases don't exercise financial aid; default to an anonymous user - // so the (auth-gated) flexible-price query is simply skipped. + // so the (auth-gated) pricing query is simply skipped. setMockResponse.get( apiUrls.userMe.get(), makeUser({ is_authenticated: false }), @@ -46,8 +47,10 @@ describe("useProgramCertificatePrice", () => { expect(result.current).toEqual({ price: formatPrice(program.min_price, { avoidCents: true }), + showsRange: false, savings: null, financialAid: null, + breakdown: null, }) }) @@ -64,8 +67,10 @@ describe("useProgramCertificatePrice", () => { expect(result.current).toEqual({ price: null, + showsRange: false, savings: null, financialAid: null, + breakdown: null, }) }) @@ -169,8 +174,8 @@ describe("useProgramCertificatePrice", () => { makeUser({ is_authenticated: true }), ) setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - makeFlexiblePrice({ product_flexible_price: null }), + urls.products.userPricingDetail(product.id), + makeUserPricing(), ) const { result } = renderHook(() => useProgramCertificatePrice(program), { @@ -180,7 +185,7 @@ describe("useProgramCertificatePrice", () => { await waitFor(() => expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ - url: urls.products.userFlexiblePriceDetail(product.id), + url: urls.products.userPricingDetail(product.id), }), ), ) @@ -188,9 +193,10 @@ describe("useProgramCertificatePrice", () => { expect(result.current.financialAid?.applied).toBe(false), ) expect(result.current.price).toBe(formatPrice(800, { avoidCents: true })) + expect(result.current.breakdown).toBeNull() }) - test("approved flexible price -> applied: true, displayed price still the full price", async () => { + test("approved flexible price that discounts nothing -> no indicator, displayed price still the full price", async () => { const product = courses.product({ price: "800" }) const program = programs.program({ enrollment_modes: [courses.enrollmentMode({ requires_payment: true })], @@ -205,8 +211,8 @@ describe("useProgramCertificatePrice", () => { makeUser({ is_authenticated: true }), ) setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - makeFlexiblePrice({ + urls.products.userPricingDetail(product.id), + makeUserPricing({ product_flexible_price: makeDiscount({ amount: "700", discount_type: "dollars-off", @@ -218,13 +224,58 @@ describe("useProgramCertificatePrice", () => { wrapper, }) + // APPROVED means mitxonline accepted the declared income, not that the + // income earned anything: this learner's tier takes nothing off, and the + // aid form has already told them they did not qualify. Neither "approved" + // nor "apply" is true, so the indicator goes away rather than claiming a + // success that did not happen. + await waitFor(() => expect(result.current.financialAid).toBeNull()) + expect(result.current.breakdown).toBeNull() + expect(result.current.price).toBe(formatPrice(800, { avoidCents: true })) + }) + + test("approved flexible price that wins the quote -> the indicator reports it", async () => { + const product = courses.product({ price: "800" }) + const program = programs.program({ + enrollment_modes: [courses.enrollmentMode({ requires_payment: true })], + products: [product], + page: { + list_price: "800", + financial_assistance_form_url: "/financial-aid/foo", + }, + }) + setMockResponse.get( + apiUrls.userMe.get(), + makeUser({ is_authenticated: true }), + ) + setMockResponse.get( + urls.products.userPricingDetail(product.id), + makeUserPricing({ + price: "800", + user_price: "400", + product_flexible_price: makeDiscount({ + discount_type: "percent-off", + }), + discount: makeUserPricingDiscount({ + discount_type: "percent-off", + payment_type: "financial-assistance", + amount_off: "400", + source: null, + }), + }), + ) + + const { result } = renderHook(() => useProgramCertificatePrice(program), { + wrapper, + }) + await waitFor(() => expect(result.current.financialAid?.applied).toBe(true), ) - expect(result.current.price).toBe(formatPrice(800, { avoidCents: true })) + expect(result.current.breakdown?.kind).toBe("aid") }) - test("free-only program with finaid url -> flexible-price endpoint is never requested", async () => { + test("free-only program with finaid url -> the quote is never requested", async () => { const product = courses.product({ price: "800" }) const program = programs.program({ enrollment_modes: [courses.enrollmentMode({ requires_payment: false })], @@ -237,7 +288,7 @@ describe("useProgramCertificatePrice", () => { apiUrls.userMe.get(), makeUser({ is_authenticated: true }), ) - // No mock for userFlexiblePriceDetail — it must NOT be requested + // No mock for userPricingDetail — it must NOT be requested const { result } = renderHook(() => useProgramCertificatePrice(program), { wrapper, @@ -250,9 +301,157 @@ describe("useProgramCertificatePrice", () => { ) expect(makeRequest).not.toHaveBeenCalledWith( expect.objectContaining({ - url: urls.products.userFlexiblePriceDetail(product.id), + url: urls.products.userPricingDetail(product.id), + }), + ) + }) + }) + describe("applied savings", () => { + /** An authenticated learner on a purchasable program, quoted as given. */ + const setupQuote = ( + quote: Partial[0]>, + programOverrides = {}, + ) => { + const product = courses.product({ price: "899" }) + const program = programs.program({ + enrollment_modes: [courses.enrollmentMode({ requires_payment: true })], + products: [product], + ...programOverrides, + }) + setMockResponse.get( + apiUrls.userMe.get(), + makeUser({ is_authenticated: true }), + ) + setMockResponse.get( + urls.products.userPricingDetail(product.id), + makeUserPricing({ id: product.id, price: "899", ...quote }), + ) + return { product, program } + } + + test("a purchase credit names the course it came from", async () => { + // The factory's default discount is the program-child credit. + const discount = makeUserPricingDiscount({ amount_off: "300" }) + const { program } = setupQuote({ user_price: "599", discount }) + + const { result } = renderHook(() => useProgramCertificatePrice(program), { + wrapper, + }) + + await waitFor(() => + expect(result.current.breakdown).toEqual({ + fullPrice: "$899", + amountOff: "$300", + todaysPrice: "$599", + sourceTitle: discount.source?.title, + kind: "credit", + }), + ) + }) + + test("the winning discount is recognized as financial aid by its payment type", async () => { + const discount = makeUserPricingDiscount({ + discount_type: "dollars-off", + payment_type: "financial-assistance", + amount_off: "500", + source: null, + }) + const { program } = setupQuote({ user_price: "399", discount }) + + const { result } = renderHook(() => useProgramCertificatePrice(program), { + wrapper, + }) + + await waitFor(() => + expect(result.current.breakdown).toEqual({ + fullPrice: "$899", + amountOff: "$500", + todaysPrice: "$399", + sourceTitle: null, + kind: "aid", + }), + ) + }) + + test("a discount that is neither still shows its amount", async () => { + // A payment type that is not aid, so a truthiness test in place of the + // comparison would misreport this as the learner's aid tier. + const discount = makeUserPricingDiscount({ + discount_type: "dollars-off", + payment_type: "sales", + amount_off: "50", + source: null, + }) + const { program } = setupQuote({ user_price: "849", discount }) + + const { result } = renderHook(() => useProgramCertificatePrice(program), { + wrapper, + }) + + await waitFor(() => + expect(result.current.breakdown).toEqual({ + fullPrice: "$899", + amountOff: "$50", + todaysPrice: "$849", + sourceTitle: null, + kind: "other", + }), + ) + }) + + test("a paid program with no purchasable product is not quoted", async () => { + setMockResponse.get( + apiUrls.userMe.get(), + makeUser({ is_authenticated: true }), + ) + const program = programs.program({ + enrollment_modes: [courses.enrollmentMode({ requires_payment: true })], + products: [], + }) + + renderHook(() => useProgramCertificatePrice(program), { wrapper }) + + // Without its own product guard the hook asks for product 0. + await waitFor(() => expect(makeRequest).toHaveBeenCalled()) + expect(makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: urls.products.userPricingDetail(0), }), ) }) + + test("a program with no financial aid form is quoted anyway", async () => { + const { product, program } = setupQuote({ user_price: "899" }) + + renderHook(() => useProgramCertificatePrice(program), { wrapper }) + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: urls.products.userPricingDetail(product.id), + }), + ), + ) + }) + + test("approved aid with no discount drops the range but keeps the savings", async () => { + const flexiblePrice = makeDiscount({ discount_type: "dollars-off" }) + const { program } = setupQuote( + { user_price: "899", product_flexible_price: flexiblePrice }, + { min_price: 250, max_price: 1000, page: { list_price: "1200" } }, + ) + + const { result } = renderHook(() => useProgramCertificatePrice(program), { + wrapper, + }) + + await waitFor(() => expect(result.current.showsRange).toBe(false)) + expect(result.current.price).toBe("$899") + expect(result.current.savings).toEqual({ + current: { min: 899, max: 899 }, + listAmount: 1200, + totalCourses: getTotalRequiredCourses(program), + }) + }) }) }) diff --git a/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.ts b/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.ts index 5970a0070e..7b3cc284c7 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.ts +++ b/frontends/main/src/app-pages/ProductPages/useProgramCertificatePrice.ts @@ -1,16 +1,10 @@ -import { useQuery } from "@tanstack/react-query" import type { V2ProgramDetail } from "@mitodl/mitxonline-api-axios/v2" -import { productQueries } from "api/mitxonline-hooks/products" -import { useUserIsAuthenticated } from "api/hooks/user" import type { PriceRange } from "@/common/mitxonline" -import type { FinancialAid } from "./enrollTypes" -import { - formatResourcePrice, - getEnrollmentType, - mitxonlineLegacyUrl, - toPriceRange, -} from "@/common/mitxonline" +import type { AppliedSavings, FinancialAid } from "./enrollTypes" +import { toNumericPrice } from "./appliedSavings" +import { getEnrollmentType, toPriceRange } from "@/common/mitxonline" import { getTotalRequiredCourses } from "./util" +import { useCertificatePricing } from "./useCertificatePricing" type ProgramSavings = { /** @@ -25,11 +19,10 @@ type ProgramSavings = { } type ProgramCertificatePriceResult = { - /** - * Formatted price — the program's advertised range when it has one, else the - * full product price. Null when there is no product price. - */ + /** See useCertificatePricing. */ price: string | null + /** See useCertificatePricing. */ + showsRange: boolean /** * Present when the bundle beats buying the member courses separately * (list price > program price). The caller decides whether/how to render @@ -37,71 +30,38 @@ type ProgramCertificatePriceResult = { */ savings: ProgramSavings | null financialAid: FinancialAid | null -} - -const toNumericPrice = (value: unknown): number | null => { - if (typeof value === "number" && Number.isFinite(value)) return value - if (typeof value === "string") { - const parsed = Number.parseFloat(value) - if (Number.isFinite(parsed)) return parsed - } - return null + /** Present only when the quote takes something off; see AppliedSavings. */ + breakdown: AppliedSavings | null } /** - * Price facts for a program's Certificate Track card: the price to display, - * savings-vs-separate-purchase data when applicable, and financial aid info. - * - * A financial aid discount is never reflected in `price` or `savings` — not even - * for a user whose flexible price is already approved. It is surfaced as text - * via `financialAid.applied` ("applied at checkout"), because the discount is - * applied later, in checkout. This differs from the pre-redesign - * `ProgramPriceRow`, which reduced the displayed price for an approved - * flexible price — that behavior is intentionally dropped here. + * Price facts for a program's Certificate Track card: everything + * useCertificatePricing answers, plus the savings-vs-separate-purchase data a + * program alone can have. */ export const useProgramCertificatePrice = ( program: V2ProgramDetail, ): ProgramCertificatePriceResult => { - const isAuthenticated = useUserIsAuthenticated() const enrollmentType = getEnrollmentType(program.enrollment_modes) - const product = program.products[0] - const financialAidUrl = program.page?.financial_assistance_form_url - const hasFinancialAid = !!(financialAidUrl && product) - - const userFlexiblePrice = useQuery({ - ...productQueries.userFlexiblePriceDetail({ productId: product?.id ?? 0 }), - enabled: - (enrollmentType === "paid" || enrollmentType === "both") && - isAuthenticated && - hasFinancialAid, - }) - - const financialAid = hasFinancialAid - ? { - href: mitxonlineLegacyUrl(financialAidUrl!), - applied: !!userFlexiblePrice.data?.product_flexible_price?.id, - // isLoading, not isPending: a disabled query stays pending forever, and - // this one is disabled for anonymous visitors, who are never approved - // and so have nothing to wait for. - pending: userFlexiblePrice.isLoading, - } - : null - // An advertised range displays even with no purchasable product, so the - // InfoBox agrees with MitxOnlineResourceCard for the same resource. Savings - // stay behind the product guard: there is nothing to have saved without a - // price you would actually pay. - const price = formatResourcePrice(program, product?.price || null) + const { price, showsRange, financialAid, breakdown } = useCertificatePricing( + program, + product, + { purchasable: enrollmentType === "paid" || enrollmentType === "both" }, + ) + // Savings stay behind the product guard: there is nothing to have saved + // without a price you would actually pay. if (!product?.price) { - return { price, savings: null, financialAid } + return { price, showsRange, savings: null, financialAid, breakdown } } + const range = toPriceRange(program) const productAmount = toNumericPrice(product.price) - const current = - toPriceRange(program) ?? - (productAmount === null ? null : { min: productAmount, max: productAmount }) + const singlePrice = + productAmount === null ? null : { min: productAmount, max: productAmount } + const current = showsRange && range ? range : singlePrice const listAmount = toNumericPrice(program.page?.list_price) // A list price that falls inside an advertised range does not beat every price // in it, so the savings framing only holds above the top of the range. @@ -114,7 +74,7 @@ export const useProgramCertificatePrice = ( } : null - return { price, savings, financialAid } + return { price, showsRange, savings, financialAid, breakdown } } export type { ProgramSavings, ProgramCertificatePriceResult } diff --git a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts index 8cce18321c..ec02f40fd4 100644 --- a/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useProgramEnrollment.ts @@ -32,6 +32,12 @@ type UseProgramEnrollmentOptions = { tracking: { placement: EnrollCtaPlacement } /** Program-as-course product pages use different button copy. */ displayAsCourse?: boolean + /** + * Label the paid action "Upgrade to Full Program". For a learner holding a + * purchase credit, and for nothing else: financial aid keeps the offering's + * own wording. Off by default, so the page-header CTA keeps that wording too. + */ + upgradeLabel?: boolean /** Behavioral: called when an unauthenticated user clicks an enroll action. */ onRequireSignup?: (anchor: HTMLButtonElement) => void } @@ -126,8 +132,9 @@ export const useProgramEnrollment = ( // tracks are independent; labels depend on the offering and displayAsCourse. const options: EnrollAction[] = [] if (offering === "paid" || offering === "both") { - const label = - offering === "both" + const label = opts?.upgradeLabel + ? "Upgrade to Full Program" + : offering === "both" ? "Earn Certificate" : opts?.displayAsCourse ? "Enroll" diff --git a/frontends/main/src/common/mitxonline.test.ts b/frontends/main/src/common/mitxonline.test.ts index d281a9b436..b83bc51cbb 100644 --- a/frontends/main/src/common/mitxonline.test.ts +++ b/frontends/main/src/common/mitxonline.test.ts @@ -1,18 +1,14 @@ -import { factories, RequirementTreeBuilder } from "api/mitxonline-test-utils" -import { DiscountTypeEnum, NodeTypeEnum } from "@mitodl/mitxonline-api-axios/v2" +import { RequirementTreeBuilder } from "api/mitxonline-test-utils" +import { NodeTypeEnum } from "@mitodl/mitxonline-api-axios/v2" import { formatPrice, formatPriceRange, formatResourcePrice, - getFlexiblePriceForProduct, getIdsFromReqTree, parseProgramRequirementSections, - priceWithDiscount, toPriceRange, } from "@/common/mitxonline" -const makeFlexiblePrice = factories.products.flexiblePrice - describe("formatPrice", () => { test.each([ { input: 100, expected: "$100" }, @@ -100,191 +96,6 @@ describe("formatResourcePrice", () => { }) }) -describe("getFlexiblePriceForProduct", () => { - test("Applies dollars-off discount correctly", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "25.00", - discount_type: DiscountTypeEnum.DollarsOff, - discount_code: "TEST25", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = getFlexiblePriceForProduct(product) - - expect(result).toBe(75) // $100 - $25 - }) - - test("Applies percent-off discount correctly", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "20.00", // 20% off - discount_type: DiscountTypeEnum.PercentOff, - discount_code: "TEST20", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = getFlexiblePriceForProduct(product) - - expect(result).toBe(80) // $100 * (1 - 20/100) - }) - - test("Applies fixed-price discount correctly", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "50.00", // Fixed price of $50 - discount_type: DiscountTypeEnum.FixedPrice, - discount_code: "FIXED50", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = getFlexiblePriceForProduct(product) - - expect(result).toBe(50) - }) - - test("Returns original price when no discount is applied", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: null, - }) - - const result = getFlexiblePriceForProduct(product) - - expect(result).toBe(100) - }) - - test("Returns original price when discount type is unrecognized", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "25.00", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - discount_type: "unknown-type" as any, - discount_code: "UNKNOWN", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = getFlexiblePriceForProduct(product) - - expect(result).toBe(100) - }) -}) - -describe("priceWithDiscount", () => { - test("Returns same price for original and final when no flexible price provided", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: null, - }) - - const result = priceWithDiscount({ product }) - - expect(result.originalPrice).toBe("$100") - expect(result.finalPrice).toBe("$100") - expect(result.isDiscounted).toBe(false) - expect(result.approvedFinancialAid).toBe(false) - }) - - test("Returns discounted price when flexible price is provided", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: null, - }) - - const flexiblePrice = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "30.00", - discount_type: DiscountTypeEnum.DollarsOff, - discount_code: "SAVE30", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = priceWithDiscount({ product, flexiblePrice }) - - expect(result.originalPrice).toBe("$100") - expect(result.finalPrice).toBe("$70") - expect(result.isDiscounted).toBe(true) - expect(result.approvedFinancialAid).toBe(true) - }) - - test("Shows no discount when flexible price results in same price", () => { - const product = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: null, - }) - - // Flexible price with 0% discount - const flexiblePrice = makeFlexiblePrice({ - price: "100.00", - product_flexible_price: { - id: 1, - amount: "0.00", - discount_type: DiscountTypeEnum.DollarsOff, - discount_code: "NODISCOUNT", - redemption_type: "one-time", - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: new Date().toISOString(), - expiration_date: new Date().toISOString(), - }, - }) - - const result = priceWithDiscount({ product, flexiblePrice }) - - expect(result.originalPrice).toBe("$100") - expect(result.finalPrice).toBe("$100") - expect(result.isDiscounted).toBe(false) - expect(result.approvedFinancialAid).toBe(true) // Has financial aid approval, just no discount - }) -}) - describe("parseProgramRequirementSections", () => { test("returns empty array for empty reqTree", () => { const root = new RequirementTreeBuilder() diff --git a/frontends/main/src/common/mitxonline/index.ts b/frontends/main/src/common/mitxonline/index.ts index c8356071fa..c6cf3ebb11 100644 --- a/frontends/main/src/common/mitxonline/index.ts +++ b/frontends/main/src/common/mitxonline/index.ts @@ -4,11 +4,9 @@ import type { CourseRunV2, CourseWithCourseRunsSerializerV2, EnrollmentMode, - ProductFlexiblePrice, V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" import { - DiscountTypeEnum, EnrollmentModeEnum, NodeTypeEnum, } from "@mitodl/mitxonline-api-axios/v2" @@ -17,17 +15,6 @@ const NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = env( "NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL", ) -const upgradeRunUrl = (product: ProductFlexiblePrice): string => { - try { - const url = new URL("/cart/add", NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL) - url.searchParams.append("product_id", String(product.id)) - return url.toString() - } catch (err) { - console.error("Error constructing upgrade URL:", err) - return "" - } -} - const canPurchaseRun = (run: CourseRunV2): boolean => { // Prefer to handle this on backend // See https://github.com/mitodl/hq/issues/9450 @@ -39,23 +26,6 @@ const canPurchaseRun = (run: CourseRunV2): boolean => { ) } -export const getFlexiblePriceForProduct = (product: ProductFlexiblePrice) => { - const flexDiscountAmount = Number(product.product_flexible_price?.amount) ?? 0 - const flexDiscountType = product.product_flexible_price?.discount_type - const price = Number(product.price) - - switch (flexDiscountType) { - case DiscountTypeEnum.DollarsOff: - return price - flexDiscountAmount - case DiscountTypeEnum.PercentOff: - return price * (1 - flexDiscountAmount / 100) - case DiscountTypeEnum.FixedPrice: - return flexDiscountAmount - default: - return price - } -} - /** * Format the numeric part of a price: * ```ts @@ -132,39 +102,6 @@ const formatResourcePrice = ( return formatPrice(advertised, { avoidCents }) } -type PriceWithDiscount = { - isDiscounted: boolean - /** - * Indicates if the product has approved financial aid - * Note: May be zero discount. - */ - approvedFinancialAid: boolean - originalPrice: string - finalPrice: string -} -const priceWithDiscount = ({ - product, - flexiblePrice, - avoidCents = true, -}: { - product: BaseProduct - flexiblePrice?: ProductFlexiblePrice - avoidCents?: boolean -}): PriceWithDiscount => { - const originalPrice = formatPrice(product.price, { avoidCents }) - const finalPrice = flexiblePrice - ? formatPrice(getFlexiblePriceForProduct(flexiblePrice), { avoidCents }) - : originalPrice - const isDiscounted = originalPrice !== finalPrice - - return { - isDiscounted, - approvedFinancialAid: !!flexiblePrice?.product_flexible_price?.id, - originalPrice, - finalPrice, - } -} - /** * Adding the `ecom-service=true` query parameter hides the MITxOnline-branded header * on the MITxOnline site. @@ -399,9 +336,7 @@ export { formatPriceRange, formatResourcePrice, toPriceRange, - priceWithDiscount, canPurchaseRun, - upgradeRunUrl, mitxonlineLegacyUrl, getEnrollmentType, getCourseEnrollmentAction, @@ -412,7 +347,6 @@ export { } export type { PriceRange, - PriceWithDiscount, EnrollmentType, CourseEnrollmentAction, ProgramRequirementSection, diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index 4315f8205f..c2348e0a2a 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -7,12 +7,20 @@ import { user, setupLocationMock, } from "@/test-utils" -import { makeRequest, setMockResponse } from "api/test-utils" +import { + makeRequest, + setMockResponse, + urls as apiUrls, + factories as apiFactories, +} from "api/test-utils" import { urls as mitxUrls, factories as mitxFactories, } from "api/mitxonline-test-utils" -import type { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" +import type { + CourseRunV2, + CourseWithCourseRunsSerializerV2, +} from "@mitodl/mitxonline-api-axios/v2" import NiceModal from "@ebay/nice-modal-react" import CourseEnrollmentDialog from "./CourseEnrollmentDialog" import { faker } from "@faker-js/faker/locale/en" @@ -33,24 +41,47 @@ const freeOnlyEnrollmentModes = () => [ mitxFactories.courses.enrollmentMode({ requires_payment: false }), ] -const enrollableRun: typeof makeCourseRun = (overrides) => - makeCourseRun({ - is_enrollable: true, - enrollment_start: faker.date.past().toISOString(), - enrollment_end: faker.date.future().toISOString(), - enrollment_modes: bothEnrollmentModes(), - ...overrides, +/** + * Registers a list-price quote for each of the run's products. The dialog + * quotes every purchasable product for a signed-in learner, so a run without + * one makes unrelated tests fail on an unmocked request. Tests that care about + * the quote re-register their own afterwards. + */ +const withQuotedProducts = (run: CourseRunV2): CourseRunV2 => { + run.products.forEach((product) => { + setMockResponse.get( + mitxUrls.products.userPricingDetail(product.id), + mitxFactories.products.userPricing({ + id: product.id, + price: product.price, + }), + ) }) + return run +} + +const enrollableRun: typeof makeCourseRun = (overrides) => + withQuotedProducts( + makeCourseRun({ + is_enrollable: true, + enrollment_start: faker.date.past().toISOString(), + enrollment_end: faker.date.future().toISOString(), + enrollment_modes: bothEnrollmentModes(), + ...overrides, + }), + ) const upgradeableRun: typeof makeCourseRun = (overrides) => - makeCourseRun({ - is_upgradable: true, - is_enrollable: true, - is_archived: false, - products: [mitxFactories.courses.product()], - enrollment_modes: bothEnrollmentModes(), - ...overrides, - }) + withQuotedProducts( + makeCourseRun({ + is_upgradable: true, + is_enrollable: true, + is_archived: false, + products: [mitxFactories.courses.product()], + enrollment_modes: bothEnrollmentModes(), + ...overrides, + }), + ) describe("CourseEnrollmentDialog", () => { const openDialog = async (course: CourseWithCourseRunsSerializerV2) => { @@ -67,6 +98,10 @@ describe("CourseEnrollmentDialog", () => { // the compliance gate. The factory default has nothing missing, so it passes // straight through; see JustInTimeDialog tests for the blocked case. setMockResponse.get(mitxUrls.userMe.get(), mitxFactories.user.user()) + setMockResponse.get( + apiUrls.userMe.get(), + apiFactories.user.user({ is_authenticated: true }), + ) }) describe("Course run dropdown", () => { @@ -515,18 +550,13 @@ describe("CourseEnrollmentDialog", () => { page: { financial_assistance_form_url: financialAidUrl }, }) - // Mock the flexible price API response when financial aid is available - if (hasFinancialAid) { - const mockFlexiblePrice = mitxFactories.products.flexiblePrice({ + setMockResponse.get( + mitxUrls.products.userPricingDetail(product.id), + mitxFactories.products.userPricing({ id: product.id, price: product.price, - product_flexible_price: null, - }) - setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - mockFlexiblePrice, - ) - } + }), + ) renderWithProviders(null) await openDialog(course) @@ -546,26 +576,13 @@ describe("CourseEnrollmentDialog", () => { }, ) - test("Displays user-specific discounted price when financial aid is available", async () => { - const originalPrice = "100.00" - const discountedAmount = "50.00" - const product = makeProduct({ price: originalPrice }) - const flexiblePrice = mitxFactories.products.flexiblePrice({ + test("Displays the quoted price beside the struck original when aid is approved", async () => { + const product = makeProduct({ price: "100.00" }) + const userPricing = mitxFactories.products.userPricing({ id: product.id, - price: originalPrice, - product_flexible_price: { - id: faker.number.int(), - amount: discountedAmount, - discount_type: "dollars-off" as const, - discount_code: faker.string.alphanumeric(8), - redemption_type: "one-time" as const, - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: faker.date.past().toISOString(), - expiration_date: faker.date.future().toISOString(), - }, + price: product.price, + user_price: "50.00", + product_flexible_price: mitxFactories.products.discount(), }) const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` const run = upgradeableRun({ products: [product] }) @@ -575,20 +592,19 @@ describe("CourseEnrollmentDialog", () => { }) setMockResponse.get( - mitxUrls.products.userFlexiblePriceDetail(product.id), - flexiblePrice, + mitxUrls.products.userPricingDetail(product.id), + userPricing, ) renderWithProviders(null) await openDialog(course) - // Wait for the flexible price API to be called and prices to be displayed - await screen.findByText("Financial assistance applied") + await screen.findByText("Financial assistance approved") expect(screen.getByText(/\$50/)).toBeInTheDocument() expect(screen.getByText(/\$100/)).toBeInTheDocument() }) - test("Does NOT call flexible price API when financial aid URL is empty", async () => { + test("A discount that is not financial assistance still lowers the price", async () => { const product = makeProduct({ price: "100.00" }) const run = upgradeableRun({ products: [product] }) const course = makeCourse({ @@ -596,15 +612,20 @@ describe("CourseEnrollmentDialog", () => { page: { financial_assistance_form_url: "" }, }) - // We're NOT setting up a mock response for the flexible price API - // If it's called, the test will fail + setMockResponse.get( + mitxUrls.products.userPricingDetail(product.id), + mitxFactories.products.userPricing({ + id: product.id, + price: product.price, + user_price: "80.00", + }), + ) renderWithProviders(null) await openDialog(course) - // Should show the regular price + expect(await screen.findByText(/\$80/)).toBeInTheDocument() expect(screen.getByText(/\$100/)).toBeInTheDocument() - // Should NOT show financial assistance link expect( screen.queryByRole("link", { name: /financial assistance/i }), ).toBeNull() diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx index 8827904098..8753aba7bb 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx @@ -20,10 +20,9 @@ import { Alert, Button, ButtonProps } from "@mitodl/smoot-design" import { canPurchaseRun, enrollmentAlertSuccessUrl, + formatPrice, getEnrollmentType, mitxonlineLegacyUrl, - PriceWithDiscount, - priceWithDiscount, } from "@/common/mitxonline" import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" import { SILENCE_ERROR_TOAST } from "api/mutation-meta" @@ -186,11 +185,26 @@ const StrickenText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, ...theme.typography.body2, })) +type DisplayPrice = { + /** What checkout charges this learner. */ + finalPrice: string + /** The product's undiscounted price, struck through when the two differ. */ + originalPrice: string + /** + * Whether the learner is approved for financial assistance -- which is not the + * same as aid having produced `finalPrice`. A learner approved at a tier that + * discounts nothing, or one whose aid a cheaper discount beat, is approved and + * paying a price aid did not set, so the copy this drives says "approved" + * rather than "applied". + */ + approvedFinancialAid: boolean +} + const NumericPriceDisplay: React.FC<{ - price: PriceWithDiscount | null + price: DisplayPrice | null }> = ({ price }) => { if (!price) return null - if (!price.isDiscounted) return price.finalPrice + if (price.finalPrice === price.originalPrice) return price.finalPrice return ( {price.finalPrice} {price.originalPrice} @@ -211,18 +225,34 @@ const CertificateUpsell: React.FC<{ // Renders its own inline error below (replaceBasketItem.isError), so suppress // the global error toast. const replaceBasketItem = useReplaceBasketItem({ meta: SILENCE_ERROR_TOAST }) - const userFlexiblePrice = useQuery({ - ...productQueries.userFlexiblePriceDetail({ + // The quote endpoint rejects anonymous requests, which this dialog cannot + // produce: it is only ever opened from the dashboard, behind + // RestrictedRoute requires={Permission.Authenticated}. + // + // Not gated on a financial assistance form either: a learner can hold an + // automatic or user-tied discount on a course that offers no aid, and the + // quote prices those too. + const userPricing = useQuery({ + ...productQueries.userPricingDetail({ productId: product?.id ?? 0, }), - enabled: enabled && !!financialAidUrl, + enabled, + // The browser query client throws 400/401/403 into the route's error + // boundary, which would replace the page behind this dialog over a stale + // mitxonline session. A quote we cannot get falls back to the list price. + throwOnError: false, }) - const price = enabled - ? priceWithDiscount({ - product, - flexiblePrice: userFlexiblePrice.data, - avoidCents: true, - }) + const price: DisplayPrice | null = enabled + ? { + // The quoted price, rather than a discount reapplied here: checkout + // picks one discount from everything the learner holds, and only it + // knows which one wins. + finalPrice: formatPrice(userPricing.data?.user_price ?? product.price, { + avoidCents: true, + }), + originalPrice: formatPrice(product.price, { avoidCents: true }), + approvedFinancialAid: !!userPricing.data?.product_flexible_price?.id, + } : null const hasFinancialAssistance = !!financialAidUrl const deadlineUI = courseRun?.upgrade_deadline ? ( @@ -259,7 +289,7 @@ const CertificateUpsell: React.FC<{ rel="noopener noreferrer" > {price.approvedFinancialAid - ? "Financial assistance applied" + ? "Financial assistance approved" : "Financial assistance available"} ) : null} diff --git a/frontends/ol-components/src/components/Popover/Popover.tsx b/frontends/ol-components/src/components/Popover/Popover.tsx index b185dab411..e35958058a 100644 --- a/frontends/ol-components/src/components/Popover/Popover.tsx +++ b/frontends/ol-components/src/components/Popover/Popover.tsx @@ -135,7 +135,14 @@ const Content = styled.div(({ theme }) => ({ type PopoverProps = Pick< PopperProps, - "anchorEl" | "placement" | "open" | "style" + | "anchorEl" + | "placement" + | "open" + | "style" + // The popover is a `dialog` when modal, and a dialog owes assistive tech a + // name; nothing inside a bare body of text can supply one. + | "aria-label" + | "aria-labelledby" > & { children?: React.ReactNode /** From aa6a85a64f7eb5ca06114320ebbc05277a82895c Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Tue, 22 Sep 2026 14:01:38 +0500 Subject: [PATCH 2/5] feat(product-pages): render CMS-managed FAQs on course and program pages (#3954) Add a FAQ accordion to MIT Learn course/program product pages, driven by author-managed FAQs from the MITx Online Wagtail CMS (mitxonline#3961). --- frontends/api/package.json | 2 +- .../mitxonline/test-utils/factories/pages.ts | 15 +- frontends/main/package.json | 2 +- .../ProductPages/CoursePage.test.tsx | 2 + .../src/app-pages/ProductPages/CoursePage.tsx | 2 + .../ProductPages/FaqsSection.test.tsx | 117 ++++++++++++++ .../app-pages/ProductPages/FaqsSection.tsx | 144 ++++++++++++++++++ .../ProductPages/ProgramAsCoursePage.test.tsx | 2 + .../ProductPages/ProgramAsCoursePage.tsx | 2 + .../ProductPages/ProgramPage.test.tsx | 4 + .../app-pages/ProductPages/ProgramPage.tsx | 2 + .../ProductPages/faqStructuredData.test.ts | 22 +++ .../ProductPages/faqStructuredData.ts | 20 +++ .../main/src/app-pages/ProductPages/util.ts | 1 + yarn.lock | 12 +- 15 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 frontends/main/src/app-pages/ProductPages/FaqsSection.test.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/FaqsSection.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/faqStructuredData.test.ts create mode 100644 frontends/main/src/app-pages/ProductPages/faqStructuredData.ts 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/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/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/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 85759c3afb..97504c97f8 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -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/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 From b8a724a60aacd06a9224053f41d8e7a0d23e98cc Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 22 Sep 2026 17:52:48 +0000 Subject: [PATCH 5/5] Release 0.80.14 --- RELEASE.rst | 8 ++++++++ main/settings.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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/main/settings.py b/main/settings.py index 0cbd76c3b5..4a72637bd1 100644 --- a/main/settings.py +++ b/main/settings.py @@ -36,7 +36,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.80.13" +VERSION = "0.80.14" log = logging.getLogger()