diff --git a/frontends/api/package.json b/frontends/api/package.json index d76f4d52a5..a229531b38 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -29,7 +29,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2026.5.1", + "@mitodl/mitxonline-api-axios": "2026.5.14", "@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/programs.ts b/frontends/api/src/mitxonline/test-utils/factories/programs.ts index 694af34a85..0836dcb6cb 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/programs.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/programs.ts @@ -40,6 +40,7 @@ const program: PartialFactory = (overrides = {}) => { length: `${faker.number.int({ min: 1, max: 12 })} weeks`, effort: `${faker.number.int({ min: 1, max: 10 })} hours/week`, price: faker.commerce.price(), + list_price: faker.commerce.price(), }, program_type: faker.helpers.arrayElement([ "certificate", diff --git a/frontends/main/package.json b/frontends/main/package.json index 6774f02fb2..02c587c5a9 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "2026.5.1", + "@mitodl/mitxonline-api-axios": "2026.5.14", "@mitodl/smoot-design": "^6.27.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 1741b4d2f7..f402da563b 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -1280,7 +1280,7 @@ describe("ProgramSummary", () => { expect(priceRow).not.toHaveTextContent("Earn a certificate") }) - test("Shows paid price with certificate type and no cert box when all enrollment modes are paid", () => { + test("Shows paid price with no cert box when all enrollment modes are paid", () => { const product = factories.courses.product({ price: "1499.00" }) const program = factories.programs.program({ enrollment_modes: [paidMode()], @@ -1292,12 +1292,13 @@ describe("ProgramSummary", () => { expect(priceRow).toHaveTextContent( formatPrice(product.price, { avoidCents: true }), ) - expect(priceRow).toHaveTextContent(program.certificate_type) + expect(priceRow).toHaveTextContent("full program") expect(priceRow).not.toHaveTextContent("Free to Learn") expect(priceRow).not.toHaveTextContent("Earn a certificate") + expect(priceRow).not.toHaveTextContent("Audit for free") }) - test("Shows 'Free to Learn' and cert box when enrollment modes include both free and paid", () => { + test("Shows paid price and 'Start for free' callout when enrollment modes include both free and paid", () => { const program = factories.programs.program({ enrollment_modes: bothModes(), }) @@ -1305,11 +1306,14 @@ describe("ProgramSummary", () => { renderWithProviders() const priceRow = screen.getByTestId(TestIds.PriceRow) - expect(priceRow).toHaveTextContent("Free to Learn") - expect(priceRow).toHaveTextContent("Earn a certificate") + expect(priceRow).toHaveTextContent("Audit for free") + expect(priceRow).toHaveTextContent("or upgrade to") + expect(priceRow).toHaveTextContent("certificate") expect(priceRow).toHaveTextContent( formatPrice(program.products[0].price, { avoidCents: true }), ) + expect(priceRow).not.toHaveTextContent("Free to Learn") + expect(priceRow).not.toHaveTextContent("Earn a certificate") }) test.each([ diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index 7e4e4a594c..2fc48ff2b9 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -1,7 +1,7 @@ import React, { HTMLAttributes, useState } from "react" import { ActionButton, Alert, styled } from "@mitodl/smoot-design" import { productQueries } from "api/mitxonline-hooks/products" -import { Dialog, Link, Skeleton, Stack, Typography } from "ol-components" +import { Dialog, Link, Skeleton, Stack, theme, Typography } from "ol-components" import type { StackProps } from "ol-components" import { RiCalendarLine, @@ -38,6 +38,14 @@ const UnderlinedLink = styled(ResponsiveLink)({ textDecoration: "underline", }) +const SecondaryUnderlinedLink = styled(UnderlinedLink)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body4, + }, +})) + const InfoRow = styled.div(({ theme }) => ({ width: "100%", display: "flex", @@ -378,6 +386,159 @@ const GrayText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +const ProgramPaySection = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "12px", + width: "346px", + alignSelf: "stretch", + flex: "none", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPayLabel = styled.span(({ theme }) => ({ + ...theme.typography.subtitle3, + color: theme.custom.colors.silverGrayDark, + textTransform: "uppercase", + letterSpacing: "0.04em", +})) + +/** Horizontal row: [current price block] | [vertical divider] | [list price block] */ +const ProgramPriceRowInner = styled.div({ + display: "flex", + flexDirection: "row" as const, + alignItems: "flex-end" as const, + gap: "24px", +}) + +const ProgramCurrentPriceBlock = styled.div({ + display: "flex", + flexDirection: "column" as const, + justifyContent: "flex-end" as const, + alignItems: "flex-start" as const, +}) + +const ProgramPriceAmount = styled.span(({ theme }) => ({ + ...theme.typography.subtitle2, + fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", + fontWeight: theme.typography.fontWeightBold, + fontSize: "34px", + lineHeight: "40px", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPriceSuffix = styled.span(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) + +const ProgramVerticalDivider = styled.div(() => ({ + width: "1px", + height: "48px", + backgroundColor: theme.custom.colors.lightGray2, + flexShrink: 0, +})) + +const ProgramListPriceBlock = styled.div({ + display: "flex", + flexDirection: "column" as const, + justifyContent: "flex-end" as const, + alignItems: "flex-start" as const, +}) + +const ProgramListPriceAmount = styled.span({ + ...theme.typography.body3, + fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", + fontSize: "28px", + lineHeight: "36px", + display: "flex", + alignItems: "flex-end" as const, + textDecoration: "line-through", + color: theme.custom.colors.silverGrayDark, +}) + +const ProgramListPriceSubLabel = styled.span({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +}) + +/** Inline row: "Save $X compared to purchasing N courses separately" */ +const ProgramDiscountRow = styled.div({ + display: "flex", + flexDirection: "row" as const, + alignItems: "center" as const, + gap: "4px", + width: "100%", +}) + +const ProgramSavingsText = styled.span({ + ...theme.typography.subtitle3, + fontWeight: theme.typography.fontWeightBold, + color: "#008000", +}) + +const ProgramSavingsDetailText = styled.span({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +}) + +const ProgramPriceDivider = styled.div(({ theme }) => ({ + width: "100%", + maxWidth: "346px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + marginBottom: "20px", + flex: "none", + alignSelf: "stretch", +})) + +const ProgramStartForFreeBox = styled.div((_theme) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "8px 16px", + borderRadius: "8px", + background: + "linear-gradient(0deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.94)), #004D1A", +})) + +const ProgramStartForFreeIcon = styled.svg(() => ({ + width: "24px", + height: "24px", + flexShrink: 0, + path: { + fill: "#008000", + }, +})) + +const ProgramStartForFreeTextContainer = styled.span(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: "4px", + ...theme.typography.body2, +})) + +const ProgramStartForFreeTextStrong = styled.span({ + ...theme.typography.subtitle2, + color: "#008000", +}) + +const ProgramStartForFreeTextRegular = styled.span({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, +}) + +const ProgramStartForFreeInfoIcon = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + flexShrink: 0, + color: theme.custom.colors.silverGrayDark, + "& svg": { + width: "20px", + height: "20px", + }, +})) + const CertificateBoxRoot = styled.div(({ theme }) => ({ width: "100%", backgroundColor: theme.custom.colors.lightGray1, @@ -567,7 +728,6 @@ enum TestIds { DurationRow = "duration-row", PriceRow = "price-row", RequirementsRow = "requirements-row", - CertificateTrackRow = "certificate-track-row", } const ArchivedAlert: React.FC = () => { @@ -657,15 +817,16 @@ type ProgramInfoRowProps = { program: V2ProgramDetail } & HTMLAttributes +const getTotalRequiredCourses = (program: V2ProgramDetail) => { + const parsedReqs = parseReqTree(program.req_tree) + return parsedReqs.reduce((sum, req) => sum + req.requiredCount, 0) +} + const RequirementsRow: React.FC = ({ program, ...others }) => { - const parsedReqs = parseReqTree(program.req_tree) - const totalRequired = parsedReqs.reduce( - (sum, req) => sum + req.requiredCount, - 0, - ) + const totalRequired = getTotalRequiredCourses(program) if (totalRequired === 0) return null // Always say "Courses" here. Whether a child program should be labeled @@ -760,28 +921,66 @@ const ProgramPaceRow: React.FC< const PROGRAM_CERT_INFO_HREF = "https://mitxonline.zendesk.com/hc/en-us/articles/28158506908699-What-is-the-Certificate-Track-What-are-Course-and-Program-Certificates" -const ProgramCertificateBox: React.FC<{ program: V2ProgramDetail }> = ({ +type ProgramPriceRowProps = HTMLAttributes & { + program: V2ProgramDetail +} +const ProgramPriceRow: React.FC = ({ program, + ...others }) => { - const price = program.products[0]?.price - if (!price) return null - return ( - - - - - Earn a certificate - - : {formatPrice(price, { avoidCents: true })} - - + const enrollmentType = getEnrollmentType(program.enrollment_modes) + if (enrollmentType === "none") return null + + const currentPrice = program.products[0]?.price + const listPrice = program.page.list_price + + const currentAmount = toNumericPrice(currentPrice) + const listAmount = toNumericPrice(listPrice) + const hasSavings = + currentAmount !== null && listAmount !== null && listAmount > currentAmount + const savingsAmount = hasSavings ? listAmount - currentAmount : null + + const totalRequired = getTotalRequiredCourses(program) + + const paidSection = currentPrice ? ( + + Price + + + + {formatPrice(currentPrice, { avoidCents: true })} + + full program + + {hasSavings && listAmount !== null ? ( + <> + + + + + + + ) : null} + + {hasSavings && savingsAmount !== null ? ( + + + Save {formatPrice(savingsAmount, { avoidCents: true })} + + + compared to purchasing {totalRequired}{" "} + {pluralize("course", totalRequired)} separately + + + ) : null} {program.page.financial_assistance_form_url ? ( - = ({ style={{ minWidth: "fit-content" }} > Financial assistance available - + ) : null} - + {enrollmentType === "both" ? ( + + + + + Audit for free + + + or upgrade to certificate + + + + + + + + ) : null} + + ) : ( + ) -} - -type ProgramPriceRowProps = HTMLAttributes & { - program: V2ProgramDetail -} -const ProgramPriceRow: React.FC = ({ - program, - ...others -}) => { - const enrollmentType = getEnrollmentType(program.enrollment_modes) - if (enrollmentType === "none") return null - - const paidPrice = - enrollmentType === "paid" && program.products[0]?.price ? ( - <> - {formatPrice(program.products[0].price, { avoidCents: true })}{" "} - (includes {program.certificate_type}) - - ) : null return ( - - - - - {enrollmentType === "paid" ? ( - + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + + ) : null} + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + paidSection ) : ( - + <> + + + + + + )} - {enrollmentType === "both" ? ( - - ) : 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 +} + const ProgramSummary: React.FC<{ program: V2ProgramDetail /** diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx index 46fa4f46aa..b86eeb4739 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx @@ -34,7 +34,8 @@ const makeUser = factories.user.user describe("ProgramEnrollmentButton", () => { const ENROLLED = "Enrolled" - const ENROLL_FREE = "Enroll for Free" + const ENROLL_FREE = "Enroll in Program" + const ENROLL_PROGRAM = "Enroll in Program" setupLocationMock() @@ -94,7 +95,7 @@ describe("ProgramEnrollmentButton", () => { expect(enrolledLink).toHaveAttribute("href", programView(program.id)) }) - test("Free-only: clicking 'Enroll for Free' enrolls and redirects to the dashboard success URL with title in params", async () => { + test("Free-only: clicking 'Enroll in Program' enrolls and redirects to the dashboard success URL with title in params", async () => { const program = makeProgram({ enrollment_modes: [makeEnrollmentMode({ requires_payment: false })], }) @@ -134,7 +135,7 @@ describe("ProgramEnrollmentButton", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) - test("Both: clicking 'Enroll for Free' opens enrollment dialog", async () => { + test("Both: clicking 'Enroll in Program' opens enrollment dialog", async () => { const program = makeProgram({ enrollment_modes: [ makeEnrollmentMode({ requires_payment: false }), @@ -157,7 +158,7 @@ describe("ProgramEnrollmentButton", () => { await screen.findByRole("dialog", { name: program.title }) }) - test("Paid-only: clicking 'Enroll Now' clears basket, adds product, and redirects", async () => { + test("Paid-only: clicking 'Enroll in Program' clears basket, adds product, and redirects", async () => { const assign = jest.mocked(window.location.assign) const product = makeProduct({ price: "500" }) const program = makeProgram({ @@ -177,7 +178,7 @@ describe("ProgramEnrollmentButton", () => { ) const enrollButton = await screen.findByRole("button", { - name: /Enroll Now/, + name: ENROLL_PROGRAM, }) await user.click(enrollButton) @@ -208,12 +209,12 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: /Enroll Now/ }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) await user.click(button) await screen.findByRole("progressbar", { name: "Loading" }) expect(button).toBeDisabled() - expect(button).toHaveTextContent("Enroll Now—$500") + expect(button).toHaveTextContent(ENROLL_PROGRAM) }) test("Shows error alert when basket operation fails (paid)", async () => { @@ -231,7 +232,7 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: /Enroll Now/ }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) await user.click(button) await screen.findByText( @@ -281,7 +282,7 @@ describe("ProgramEnrollmentButton", () => { ) const button = await screen.findByRole("button", { - name: "Enroll Now—$1,500", + name: ENROLL_PROGRAM, }) expect(button).toBeInTheDocument() expect(button).not.toBeDisabled() @@ -319,7 +320,7 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: "Enroll Now" }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) expect(button).toBeDisabled() }) @@ -398,9 +399,7 @@ describe("ProgramEnrollmentButton", () => { renderWithProviders( , ) - await user.click( - await screen.findByRole("button", { name: "Enroll for Free" }), - ) + await user.click(await screen.findByRole("button", { name: ENROLL_FREE })) expect(mockCapture).toHaveBeenCalledWith( PostHogEvents.CallToActionClicked, @@ -426,9 +425,7 @@ describe("ProgramEnrollmentButton", () => { renderWithProviders( , ) - await user.click( - await screen.findByRole("button", { name: "Enroll for Free" }), - ) + await user.click(await screen.findByRole("button", { name: ENROLL_FREE })) expect(mockCapture).toHaveBeenCalledWith( PostHogEvents.CallToActionClicked, diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx index d3571fe900..4cbaba2159 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx @@ -1,12 +1,12 @@ import React from "react" -import { LoadingSpinner, Stack } from "ol-components" +import { LoadingSpinner, Stack, theme } from "ol-components" import { enrollmentQueries, useCreateProgramEnrollment, } from "api/mitxonline-hooks/enrollment" import { useQuery } from "@tanstack/react-query" import { V2ProgramDetail } from "@mitodl/mitxonline-api-axios/v2" -import { RiCheckLine } from "@remixicon/react" +import { RiArrowRightSLine, RiCheckLine } from "@remixicon/react" import { Alert, Button, @@ -22,7 +22,6 @@ import { programView } from "@/common/urls" import { usePostHog } from "posthog-js/react" import { enrollmentAlertSuccessUrl, - formatPrice, getEnrollmentType, } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" @@ -36,6 +35,20 @@ const ButtonLinkWithDisabled = styled(ButtonLink)(({ href }) => [ }, ]) +const EnrollButtonIcon = styled.span({ + width: "24px", + height: "24px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + color: theme.custom.colors.white, + "> svg": { + width: "24px", + height: "24px", + }, +}) + type ProgramEnrollmentButtonProps = { program: V2ProgramDetail variant?: ButtonProps["variant"] @@ -63,16 +76,7 @@ const ProgramEnrollmentButton: React.FC = ({ const enrollmentType = getEnrollmentType(program.enrollment_modes) const isPaidWithoutPrice = enrollmentType === "paid" && !program.products[0]?.price - - const getEnrollButtonText = () => { - if (enrollmentType === "paid") { - const price = program.products[0]?.price - return price - ? `Enroll Now—${formatPrice(price, { avoidCents: true })}` - : "Enroll Now" - } - return "Enroll for Free" - } + const enrollButtonLabel = "Enroll in Program" const isLoading = enrollments.isLoading || me.isLoading const isPending = @@ -88,7 +92,7 @@ const ProgramEnrollmentButton: React.FC = ({ posthog.capture(PostHogEvents.CallToActionClicked, { readableId: program.readable_id, resourceType: "program", - label: getEnrollButtonText(), + label: enrollButtonLabel, }) } if (me.data?.is_authenticated) { @@ -144,10 +148,14 @@ const ProgramEnrollmentButton: React.FC = ({ endIcon={ isLoading || isPending ? ( - ) : undefined + ) : ( + + + ) } > - {isLoading ? null : getEnrollButtonText()} + {isLoading ? null : enrollButtonLabel} )} {isError && ( diff --git a/yarn.lock b/yarn.lock index d2ac5fe0cb..3aa5f65cd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3332,13 +3332,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:2026.5.1": - version: 2026.5.1 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.5.1" +"@mitodl/mitxonline-api-axios@npm:2026.5.14": + version: 2026.5.14 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.5.14" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/6eb179298221fc2801ce4e3c0589de0378c68b7a32503101de675a85f6e3569c78ec6cb4b1bc5aa85094637532a34d10c110a4a9cdfa96c525dc4362e0236d5c + checksum: 10/24780dbfd2c9cf50c9b3ec877b4cad18bd30ca01202445215fdaadbf627bdf961d04354e7329cd05e2d5d58ad02d539972769d4848e682eaaf5aca57fdbca63f languageName: node linkType: hard @@ -8939,7 +8939,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:2026.5.1" + "@mitodl/mitxonline-api-axios": "npm:2026.5.14" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16184,7 +16184,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "npm:2026.5.1" + "@mitodl/mitxonline-api-axios": "npm:2026.5.14" "@mitodl/smoot-design": "npm:^6.27.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3"