From 3fe904ba51911adfbc998c8cbd8823699f3537fd Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Wed, 23 Sep 2026 11:55:49 -0400 Subject: [PATCH 1/4] Track begin-checkout analytics for enrollments (#3959) * Track begin-checkout analytics for enrollments Adds `trackBeginCheckout` when a user starts checkout from both the dashboard enrollment flow and the course enrollment dialog. This complements the existing enrollment and cart analytics so purchase funnel tracking reflects the full enrollment journey. * Add trackBeginCheckout and trackAddToCart assertions to enrollment tests Co-Authored-By: Claude Sonnet 4.6 * Enrich begin checkout analytics event Update begin-checkout GTM tracking to send GA4-friendly checkout data, including course ID, value, currency, and item details. The enrollment flow and related tests now pass structured checkout params instead of only the course name. * Pass course metadata to checkout tracking Update course enrollment to call `trackBeginCheckout` with structured analytics data instead of only the course title. The payload now includes the course name, readable ID, and parsed product price so checkout events capture the metadata expected by downstream tracking. --------- Co-authored-by: Claude Sonnet 4.6 --- .../UnenrolledCourseCard.test.tsx | 11 ++++- .../hooks/useEnrollmentHandler.ts | 9 +++- .../ProductPages/useCourseEnrollment.test.tsx | 8 +++- .../ProductPages/useCourseEnrollment.ts | 6 ++- .../main/src/common/analytics/gtm.test.ts | 41 ++++++++++++++++--- frontends/main/src/common/analytics/gtm.ts | 28 ++++++++++++- .../CourseEnrollmentDialog.test.tsx | 17 ++++++++ .../CourseEnrollmentDialog.tsx | 11 ++++- 8 files changed, 119 insertions(+), 12 deletions(-) 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 8b23ff115b..c18b921b84 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx @@ -16,11 +16,12 @@ import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" import { UnenrolledCourseCard } from "./UnenrolledCourseCard" -import { trackCourseEnrolled } from "@/common/analytics/gtm" +import { trackCourseEnrolled, trackBeginCheckout } from "@/common/analytics/gtm" jest.mock("@/common/analytics/gtm", () => ({ ...jest.requireActual("@/common/analytics/gtm"), trackCourseEnrolled: jest.fn(), + trackBeginCheckout: jest.fn(), })) /** @@ -883,6 +884,14 @@ describe.each([ ) }) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + courseId: course.readable_id, + value: parseFloat(product.price), + }), + ) + expect( screen.queryByRole("dialog", { name: course.title }), ).not.toBeInTheDocument() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts index 57779b752b..30ef35c792 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts @@ -13,7 +13,7 @@ import NiceModal from "@ebay/nice-modal-react" import { getCourseEnrollmentAction } from "@/common/mitxonline" import { useComplianceGate } from "@/common/mitxonline/useComplianceGate" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -import { trackCourseEnrolled } from "@/common/analytics/gtm" +import { trackCourseEnrolled, trackBeginCheckout } from "@/common/analytics/gtm" import { canOpenCourseware } from "../courseDateUtils" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" @@ -170,6 +170,13 @@ export const useEnrollmentHandler = () => { } if (enrollmentAction.type === "checkout") { + trackBeginCheckout({ + courseName: course.title, + courseId: course.readable_id, + value: enrollmentAction.product.price + ? parseFloat(enrollmentAction.product.price) + : 0, + }) replaceBasketItem.mutate(enrollmentAction.product.id) return } diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 11a04b621b..6e021e538e 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -391,7 +391,13 @@ describe("useCourseEnrollment — actions", () => { paidOption!.onClick!(fakeEvent) expect(trackStartEnrollment).toHaveBeenCalledWith(course.title) - expect(trackBeginCheckout).toHaveBeenCalledWith(course.title) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + courseId: course.readable_id, + value: parseFloat(product.price), + }), + ) await waitFor(() => expect(makeRequest).toHaveBeenCalledWith( diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 5ea91ba570..8a0f815a31 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -105,7 +105,11 @@ export const useCourseEnrollment = ( if (kind === "paid") { const product = selectedRun?.products?.[0] if (product) { - trackBeginCheckout(course.title) + trackBeginCheckout({ + courseName: course.title, + courseId: course.readable_id, + value: product.price ? parseFloat(product.price) : 0, + }) replaceBasketItem.mutate(product.id) } } else if (kind === "free") { diff --git a/frontends/main/src/common/analytics/gtm.test.ts b/frontends/main/src/common/analytics/gtm.test.ts index fb966e120d..e18be11262 100644 --- a/frontends/main/src/common/analytics/gtm.test.ts +++ b/frontends/main/src/common/analytics/gtm.test.ts @@ -282,17 +282,48 @@ describe("trackReturnVisit", () => { }) describe("trackBeginCheckout", () => { - it("pushes a begin-checkout event with course name", () => { - trackBeginCheckout("Data Science Fundamentals") + it("pushes a begin-checkout event with all fields", () => { + trackBeginCheckout({ + courseName: "Data Science Fundamentals", + courseId: "course-v1:MITx+6.86x", + value: 149, + }) expect(window.dataLayer).toContainEqual({ event: "begin-checkout", "course-name": "Data Science Fundamentals", + "course-id": "course-v1:MITx+6.86x", + currency: "USD", + value: 149, + items: [ + { + item_id: "course-v1:MITx+6.86x", + item_name: "Data Science Fundamentals", + price: 149, + quantity: 1, + currency: "USD", + }, + ], }) }) - it("pushes a begin-checkout event without course name when null", () => { - trackBeginCheckout(null) - expect(window.dataLayer).toContainEqual({ event: "begin-checkout" }) + it("defaults value to 0 and currency to USD when not provided", () => { + trackBeginCheckout({ courseName: "Data Science Fundamentals" }) + expect(window.dataLayer).toContainEqual( + expect.objectContaining({ + event: "begin-checkout", + currency: "USD", + value: 0, + }), + ) + }) + + it("omits course-name and course-id when not provided", () => { + trackBeginCheckout({ value: 99 }) + expect(window.dataLayer).toContainEqual( + expect.objectContaining({ event: "begin-checkout", value: 99 }), + ) + expect(window.dataLayer![0]).not.toHaveProperty("course-name") + expect(window.dataLayer![0]).not.toHaveProperty("course-id") }) }) diff --git a/frontends/main/src/common/analytics/gtm.ts b/frontends/main/src/common/analytics/gtm.ts index 5b1f07c6f5..c66ee26142 100644 --- a/frontends/main/src/common/analytics/gtm.ts +++ b/frontends/main/src/common/analytics/gtm.ts @@ -220,14 +220,37 @@ const trackReturnVisit = () => { pushGtmEvent("return-visit") } +type BeginCheckoutParams = { + courseName?: string | null + courseId?: string | null + value?: number | null + currency?: string | null +} + /** * Fired when a user begins the paid checkout flow. * Maps to "Begin Checkout" in the marketing event plan. * More specific than trackStartEnrollment — fires only for the checkout path. + * Includes currency, value, and items so GA4 built-in funnel and revenue + * reports are populated between add_to_cart and purchase. */ -const trackBeginCheckout = (courseName?: string | null) => { +const trackBeginCheckout = (params: BeginCheckoutParams) => { + const currency = params.currency ?? "USD" + const value = params.value ?? 0 pushGtmEvent("begin-checkout", { - ...(courseName ? { "course-name": courseName } : {}), + ...(params.courseName ? { "course-name": params.courseName } : {}), + ...(params.courseId ? { "course-id": params.courseId } : {}), + currency, + value, + items: [ + { + item_id: params.courseId ?? "", + item_name: params.courseName ?? "", + price: value, + quantity: 1, + currency, + }, + ], }) } @@ -311,6 +334,7 @@ export { export type { AddToCartParams, + BeginCheckoutParams, CatalogFilterParams, CourseProgramViewParams, CheckoutCompletedParams, diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index c2348e0a2a..a8f8e9b44f 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -27,6 +27,13 @@ import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import * as routes from "@/common/urls" +import { trackAddToCart, trackBeginCheckout } from "@/common/analytics/gtm" + +jest.mock("@/common/analytics/gtm", () => ({ + ...jest.requireActual("@/common/analytics/gtm"), + trackAddToCart: jest.fn(), + trackBeginCheckout: jest.fn(), +})) const makeCourseRun = mitxFactories.courses.courseRun const makeProduct = mitxFactories.courses.product @@ -440,6 +447,16 @@ describe("CourseEnrollmentDialog", () => { // Verify redirect to cart page expect(assign).toHaveBeenCalledWith(mitxonlineLegacyUrl("/cart/")) + + expect(trackAddToCart).toHaveBeenCalledWith( + expect.objectContaining({ courseName: course.title }), + ) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + value: parseFloat(product.price), + }), + ) }) test("Default behavior: redirects to the dashboard success URL with title in params after enrollment", async () => { diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx index 8753aba7bb..3142cb963b 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx @@ -31,7 +31,11 @@ import { useComplianceGate } from "@/common/mitxonline/useComplianceGate" import { useRouter } from "next-nprogress-bar" import { useQuery } from "@tanstack/react-query" import { productQueries } from "api/mitxonline-hooks/products" -import { trackCourseEnrolled, trackAddToCart } from "@/common/analytics/gtm" +import { + trackCourseEnrolled, + trackAddToCart, + trackBeginCheckout, +} from "@/common/analytics/gtm" interface CourseEnrollmentDialogProps { course: CourseWithCourseRunsSerializerV2 @@ -310,6 +314,11 @@ const CertificateUpsell: React.FC<{ courseName: course?.title, coursePrice: product.price ? parseFloat(product.price) : 0, }) + trackBeginCheckout({ + courseName: course?.title, + courseId: course?.readable_id ?? String(product.id), + value: product.price ? parseFloat(product.price) : 0, + }) replaceBasketItem.mutate(product.id) }} /> From 35a5aba85a480b027af63da5451f05fdd5e38c08 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 23 Sep 2026 13:33:09 -0400 Subject: [PATCH 2/4] feat: add hover explanation for Active learners, fix clipped month label in engagement chart (#3966) --- .../Analytics/EngagementTrendChart.tsx | 80 ++++++++++++++++++- .../DashboardPage/Analytics/charts.test.tsx | 72 ++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx index 647df5d51d..498b99d657 100644 --- a/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx @@ -2,7 +2,8 @@ import React from "react" import { LineChart } from "@mui/x-charts/LineChart" -import { Skeleton, styled, useTheme } from "ol-components" +import { RiInformationLine } from "@remixicon/react" +import { Skeleton, styled, Tooltip, useTheme } from "ol-components" import type { MonthlyEngagementTrend } from "api/analytics-hooks/organizations" import { EmptyTableMessage, @@ -66,6 +67,34 @@ const TableWrapper = styled.div(({ theme }) => ({ borderTop: `1px solid ${theme.custom.colors.lightGray2}`, })) +const InfoTrigger = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + verticalAlign: "middle", + marginLeft: "4px", + color: theme.custom.colors.silverGrayDark, + cursor: "help", + "& svg": { width: "16px", height: "16px", marginLeft: "4px" }, +})) + +/** + * The desktop header housing the same trigger is `display: none` below `md` + * (`TableHeaderRow`), so this repeats it once, outside the table, in the one + * layout where that header is hidden — never per row, which would turn one + * definition into a repeated focus stop for every month. + */ +const MobileColumnHelp = styled.div(({ theme }) => ({ + display: "none", + [theme.breakpoints.down("md")]: { + display: "flex", + flexWrap: "wrap", + gap: "16px", + marginBottom: "12px", + ...theme.typography.subtitle2, + color: theme.custom.colors.black, + }, +})) + const CHART_HEIGHT = 320 const COLUMN_FLEX = { @@ -82,24 +111,40 @@ const SERIES = [ column: "active", label: "Active learners", color: CATEGORICAL[0], + /** + * Copied from the `monthly_active_learners` field description in + * ol-analytics-api's b2b_dashboard models (mitodl/ol-analytics-api#57), + * itself derived from the backing dbt SQL in ol-data-platform. That + * description lives only in the OpenAPI schema (/openapi.json, /docs) — + * the actual row data this component fetches never carries it, and this + * client is hand-written rather than generated from the schema (see + * the header comment in analytics/types.ts), so there is no fetch-and- + * parse step that could keep this in sync automatically. If the backend + * description changes, this string has to be updated by hand to match. + */ + description: + "Learners who did anything in a course this month: watched a video, attempted a problem, posted in a discussion, used the chatbot, moved through course pages or earned a certificate. Enrolling alone doesn't count. If too few learners were active, the whole month is withheld to avoid identifying them.", }, { key: "new_enrollments", column: "enrollments", label: "New enrollments", color: CATEGORICAL[1], + description: undefined, }, { key: "certificates_earned", column: "certificates", label: "Certificates earned", color: CATEGORICAL[2], + description: undefined, }, ] as const satisfies ReadonlyArray<{ key: keyof MonthlyEngagementTrend column: keyof typeof COLUMN_FLEX label: string color: string + description?: string }> const EngagementTrendChart: React.FC<{ @@ -152,7 +197,16 @@ const EngagementTrendChart: React.FC<{
+ + {SERIES.filter((series) => series.description).map((series) => ( + + {/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */} + + {series.label} + + + ))} +
@@ -215,6 +283,14 @@ const EngagementTrendChart: React.FC<{ $numeric > {series.label} + {series.description ? ( + + {/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */} + + + + ) : null} ))} diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx index a394a42376..d315010527 100644 --- a/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx @@ -1,5 +1,11 @@ import React from "react" -import { render, screen, within } from "@testing-library/react" +import { + render, + screen, + waitForElementToBeRemoved, + within, +} from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { ThemeProvider, useTheme } from "ol-components" import type { Theme } from "ol-components" import { factories } from "api/analytics-test-utils" @@ -144,6 +150,70 @@ describe("EngagementTrendChart", () => { ).toBeInTheDocument() }) + /** + * "Active learners" alone doesn't say what counts as active — the hover + * icon's accessible label carries the definition for keyboard and screen + * reader users, not just pointer hover. Scoped to the table: `SERIES` with + * the trigger's description also drives a second, mobile-only copy of this + * same trigger outside the table (see the test below), so an unscoped query + * would match both. + */ + test("explains what counts as an active learner from the column header", () => { + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + expect( + within(table).getByLabelText( + /Learners who did anything in a course this month/, + ), + ).toBeInTheDocument() + }) + + /** + * The column header carrying that trigger is hidden below the `md` + * breakpoint (`TableHeaderRow`), so mobile/tablet users need an equivalent + * — once, outside the table, not repeated for every month's row. + */ + test("repeats the active-learner definition once for mobile, outside the table", () => { + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + const triggers = screen.getAllByLabelText( + /Learners who did anything in a course this month/, + ) + expect(triggers).toHaveLength(2) + expect(triggers.some((trigger) => !table.contains(trigger))).toBe(true) + }) + + /** + * A static aria-label proves nothing about whether the `Tooltip` itself + * works — this test would still pass if `Tooltip` were deleted entirely. + * Driving real hover and asserting the rendered popper text catches that; + * keyboard focus isn't asserted here because MUI only opens on focus when + * `:focus-visible` matches (`isFocusVisible`), which this test environment + * doesn't set for a programmatic `.focus()` call — see the same caveat in + * ContractAdminPage.test.tsx's tooltip test. + */ + test("shows the active-learner definition on hover", async () => { + const user = userEvent.setup() + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + const trigger = within(table).getByLabelText( + /Learners who did anything in a course this month/, + ) + + await user.hover(trigger) + expect( + await screen.findByRole("tooltip", { + name: /Learners who did anything in a course this month/, + }), + ).toBeInTheDocument() + + await user.unhover(trigger) + await waitForElementToBeRemoved(() => screen.queryByRole("tooltip")) + }) + test("shows an empty state rather than an empty chart", () => { renderWithTheme() expect( From 6ee856834df768a5c8bafb0aa9c9aa94503b9e28 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 23 Sep 2026 14:34:21 -0400 Subject: [PATCH 3/4] fix: align Organizational Learning page with Figma design QA (#3971) --- frontends/main/package.json | 2 +- .../CaseStudiesSection.tsx | 11 +- .../ClientLogosSection.tsx | 4 +- .../OrganizationalLearningPage/FaqSection.tsx | 6 +- .../FeaturedProgramSection.tsx | 10 +- .../GetInTouchSection.tsx | 6 +- .../HeroSection.tsx | 106 +++++++++--------- .../OfferingsSection.tsx | 3 + .../OrgLeadForm.tsx | 15 ++- .../OrganizationalLearningPage.test.tsx | 6 +- .../SectionLayout.tsx | 5 +- .../OrganizationalLearningPage/copy.ts | 28 +++-- frontends/ol-components/package.json | 2 +- .../src/components/ReCaptcha/ReCaptcha.tsx | 12 +- .../src/components/ReCaptcha/index.ts | 2 +- yarn.lock | 12 +- 16 files changed, 134 insertions(+), 96 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index 1001ef5e62..1fbf28aabe 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -19,7 +19,7 @@ "@mitodl/course-search-utils": "^3.8.1", "@mitodl/hacksnack": "^0.1.2", "@mitodl/mitxonline-api-axios": "2026.9.21", - "@mitodl/smoot-design": "6.36.0", + "@mitodl/smoot-design": "6.38.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx index 3a7cc0f473..376a867885 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef, useState } from "react" import Image from "next/image" -import { styled, pxToRem } from "ol-components" +import { styled } from "ol-components" import { CarouselV2 } from "ol-components/CarouselV2" import { VisuallyHidden } from "@mitodl/smoot-design" import { @@ -28,10 +28,10 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", flexDirection: "column", gap: "48px", - padding: "96px 24px 40px", + padding: "96px 0 40px", [theme.breakpoints.down("md")]: { gap: "32px", - padding: "32px 24px 16px", + padding: "32px 0 16px", }, })) @@ -225,7 +225,7 @@ const PillarTitle = styled.h4(({ theme }) => ({ })) const PillarBody = styled.p(({ theme }) => ({ - ...theme.typography.body2, + ...theme.typography.body2Loose, color: theme.custom.colors.darkGray2, margin: 0, })) @@ -251,8 +251,7 @@ const QuoteMark = styled.span(({ theme }) => ({ })) const QuoteText = styled.p(({ theme }) => ({ - ...theme.typography.body2, - lineHeight: pxToRem(22), + ...theme.typography.body2Loose, color: theme.custom.colors.darkGray2, margin: 0, marginTop: "-16px", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx index 8c773af743..99dbbcc456 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx @@ -15,9 +15,9 @@ const Inner = styled(SectionInner)(({ theme }) => ({ flexDirection: "column", alignItems: "center", gap: "40px", - padding: "40px 24px 96px", + padding: "40px 0 96px", [theme.breakpoints.down("md")]: { - padding: "16px 24px 32px", + padding: "16px 0 32px", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx index 75882fcbc4..bd80485a72 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx @@ -89,7 +89,11 @@ const FaqRow: React.FC<{ index: number; question: string; answer: string }> = ({ const panelId = `faq-panel-${index}` return ( - setExpanded(!expanded)}> + setExpanded(!expanded)} + > ({ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", - gap: "48px", + gap: "100px", [theme.breakpoints.down("lg")]: { flexDirection: "column", alignItems: "stretch", + gap: "48px", }, })) const Copy = styled.div(({ theme }) => ({ display: "flex", flexDirection: "column", - gap: "32px", + gap: "40px", flex: "0 0 608px", [theme.breakpoints.down("lg")]: { flex: "initial", @@ -64,13 +65,14 @@ const Tagline = styled.p(({ theme }) => ({ ...theme.typography.h3, color: theme.custom.colors.darkGray2, margin: 0, + textWrap: "balance", [theme.breakpoints.down("md")]: { ...theme.typography.h4, }, })) const Body = styled.p(({ theme }) => ({ - ...theme.typography.body1, + ...theme.typography.body1Loose, color: theme.custom.colors.silverGrayDark, margin: 0, })) @@ -110,6 +112,7 @@ const Highlight = styled.li(({ theme }) => ({ const Actions = styled.div(({ theme }) => ({ display: "flex", + marginTop: "24px", [theme.breakpoints.down("sm")]: { button: { width: "100%" }, }, @@ -131,6 +134,7 @@ const Curriculum = styled.div(({ theme }) => ({ const CurriculumEyebrow = styled.p(({ theme }) => ({ ...theme.typography.subtitle3, + fontWeight: theme.typography.fontWeightRegular, color: theme.custom.colors.silverGrayLight, margin: 0, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx index 385efc1264..9165e6dc7b 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx @@ -19,14 +19,14 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", gap: "48px", - padding: "120px 24px", + padding: "120px 0", [theme.breakpoints.down("lg")]: { alignItems: "stretch", flexDirection: "column", - padding: "48px 24px", + padding: "48px 0", }, [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", gap: "32px", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx index 36f55c4d2c..1e488c4fd2 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx @@ -11,7 +11,7 @@ const FIGURE_RADIUS = "8px 80px 8px 50px" const OUTLINE_RADIUS = "8px 50px 8px 50px" const HeroBand = styled(Section)(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, + backgroundColor: theme.custom.colors.backgroundGray, backgroundImage: "url('/images/organizational_learning/hero-illustration.svg')", backgroundSize: "210% auto", @@ -26,15 +26,15 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", gap: "100px", - padding: "144px 24px", + padding: "144px 0", [theme.breakpoints.down("lg")]: { flexDirection: "column", alignItems: "stretch", gap: "48px", - padding: "64px 24px", + padding: "64px 0", }, [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", }, })) @@ -63,15 +63,18 @@ const Title = styled.h1(({ theme }) => ({ })) const Emphasis = styled.span(({ theme }) => ({ - borderBottom: `4px solid ${theme.custom.colors.brightRed}`, - paddingBottom: "2px", + textDecorationLine: "underline", + textDecorationColor: theme.custom.colors.brightRed, + textDecorationThickness: "4px", + textUnderlineOffset: "0", + textDecorationSkipInk: "none", [theme.breakpoints.down("md")]: { - borderBottomWidth: "3px", + textDecorationThickness: "3px", }, })) const Body = styled.p(({ theme }) => ({ - ...theme.typography.body1, + ...theme.typography.body1Loose, color: theme.custom.colors.darkGray2, margin: 0, })) @@ -126,6 +129,7 @@ const Figure = styled.div(({ theme }) => ({ inset: "-21px 19px 21px -21px", border: `1px solid ${theme.custom.colors.red}`, borderRadius: OUTLINE_RADIUS, + zIndex: 1, }, [theme.breakpoints.down("lg")]: { minHeight: "320px", @@ -141,50 +145,46 @@ const HeroImage = styled(Image)({ borderRadius: FIGURE_RADIUS, }) -const HeroSection: React.FC = () => { - const lastSpace = copy.title.lastIndexOf(" ") - const titleStart = copy.title.slice(0, lastSpace) - const titleEnd = copy.title.slice(lastSpace + 1) - - return ( - - - - - {titleStart} <Emphasis>{titleEnd}</Emphasis> - - {copy.body} - - {copy.stats.map((stat) => ( - - - {stat.label.map((line, index) => ( - - {index > 0 ?
: null} - {line} -
- ))} -
- {stat.value} -
- ))} -
- - {copy.ctaLabel} - -
-
- -
-
-
- ) -} +const HeroSection: React.FC = () => ( + + + + + {copy.title.start} + <Emphasis>{copy.title.emphasis}</Emphasis> + {copy.title.end} + + {copy.body} + + {copy.stats.map((stat) => ( + + + {stat.label.map((line, index) => ( + + {index > 0 ?
: null} + {line} +
+ ))} +
+ {stat.value} +
+ ))} +
+ + {copy.ctaLabel} + +
+
+ +
+
+
+) export default HeroSection diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx index 1c6809a202..2521c2141d 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx @@ -106,6 +106,9 @@ const CardTitle = styled.h3(({ theme }) => ({ ...theme.typography.h4, color: theme.custom.colors.darkGray2, margin: 0, + [theme.breakpoints.up("md")]: { + minHeight: `calc(${theme.typography.h4.lineHeight} * 2)`, + }, })) const CardTagline = styled.p(({ theme }) => ({ diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx index 69054ce403..f2381dca77 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx @@ -1,7 +1,13 @@ "use client" import React from "react" -import { HubspotForm, Link, styled, type HubspotFormValue } from "ol-components" +import { + HubspotForm, + Link, + RECAPTCHA_MARGIN_VAR, + styled, + type HubspotFormValue, +} from "ol-components" import { Button, ButtonLink, @@ -121,6 +127,13 @@ const StyledHubspotForm = styled(HubspotForm)(({ theme }) => ({ minWidth: 0, }, }, + /** + * ReCaptcha's own vertical margin stacks on top of this form's 24px gap and + * makes the space around it uneven with every other gap in the form. + * Cancel it via its margin custom property so the form's own gap is the + * only spacing mechanism. + */ + [RECAPTCHA_MARGIN_VAR]: 0, [theme.breakpoints.down("md")]: { fieldset: { flexDirection: "column", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx index cbe9d2f2aa..e38e92081b 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx @@ -23,6 +23,8 @@ jest.mock("ol-utilities", () => ({ const mockScrollToElement = jest.mocked(scrollToElement) +const heroTitleText = `${hero.title.start}${hero.title.emphasis}${hero.title.end}` + const setupApis = () => { setMockResponse.get(urls.userMe.get(), {}) } @@ -39,7 +41,7 @@ describe("OrganizationalLearningPage", () => { // Asserted by heading rather than by copy string so a marketing rewrite // does not break the test — what matters is that no section went missing. const sectionHeadings = [ - hero.title, + heroTitleText, featuredProgram.title, offerings.title, deliveryFormats.title, @@ -73,7 +75,7 @@ describe("OrganizationalLearningPage", () => { renderWithProviders() assertHeadings([ - { level: 1, name: hero.title }, + { level: 1, name: heroTitleText }, { level: 2, name: featuredProgram.title }, { level: 2, name: offerings.title }, ...offerings.cards.map((card) => ({ level: 3, name: card.title })), diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx index 05771b051b..bb1c9b4e11 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx @@ -19,14 +19,15 @@ export const Section = styled.section({ display: "flex", justifyContent: "center", width: "100%", + padding: "0 24px", }) export const SectionInner = styled.div(({ theme }) => ({ width: "100%", maxWidth: "1276px", - padding: "96px 24px", + padding: "96px 0", [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts b/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts index 53b5cfd803..509ffb8009 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts @@ -6,6 +6,7 @@ * it, rather than an edit spread across eight section components. */ +export type EmphasizedTitle = { start: string; emphasis: string; end: string } export type StatItem = { value: string; label: string[] } export type IconBoxItem = { title: string; body: string } export type OfferingCardItem = { @@ -52,18 +53,21 @@ export type CaseStudyItem = { } export const hero = { - title: - "Transform your organization with scalable education from MIT Open Learning", + title: { + start: "Transform your organization with ", + emphasis: "scalable education", + end: " from MIT Open Learning", + } satisfies EmphasizedTitle, body: "We work with businesses, schools, and government agencies to educate diverse workforces on the most impactful technologies of the moment. Connect with our team to learn how our portfolio of MIT Open Learning courses and programs can be matched to your organization’s training goals.", ctaLabel: "Talk with our team", image: "/images/uai_landing/uai-landing-hero-2.jpg", imageAlt: "", stats: [ - { value: "700+", label: ["Corporate", "and University Clients"] }, - { value: "150+", label: ["MIT Faculty", "& Expert Contributors"] }, + { value: "700+", label: ["Corporate", "& university clients"] }, + { value: "150+", label: ["MIT faculty", "& expert contributors"] }, { value: "500+", - label: ["Unique courses and programs"], + label: ["Unique courses & programs"], }, ] satisfies StatItem[], } @@ -77,7 +81,7 @@ export const featuredProgram = { "Self-paced program", "MIT faculty & experts", "Stackable modules", - "AI Tutor", + "AI tutor", "Translation available", ], curriculum: { @@ -96,7 +100,7 @@ export const featuredProgram = { title: "EXAMPLE INDUSTRY APPLICATION MODULES", modules: [ "AI + Healthcare", - "AI + Sustainability and Energy", + "AI + Sustainability & Energy", "AI + Finance", ], }, @@ -107,7 +111,7 @@ export const featuredProgram = { export const offerings = { eyebrow: "FOR YOUR ORGANIZATION", - title: "Learning Solutions for Every Role and Responsibility", + title: "Learning solutions for every role and responsibility", body: "Flexible learning options designed around your goals, whether you're adopting AI, strengthening technical capabilities, or building leadership skills.", cards: [ { @@ -155,15 +159,15 @@ export const deliveryFormats = { "Our team will learn about your priorities and needs to find the right combination of learning modalities.", items: [ { - title: "Courses & Long Programs", + title: "Courses & long programs", body: "Long-form digital instruction designed to build foundational or advanced-level understanding over multiple weeks or months.", }, { - title: "Short Programs & Microcredentials", + title: "Short programs & microcredentials", body: "Shorter, digital introductions designed to elevate learners’ conversational abilities on complex topics in a matter of hours.", }, { - title: "Workshops & Webinars", + title: "Workshops & webinars", body: "In-person or virtual programs on technical and leadership topics, tailored to your team’s needs, delivered live by MIT faculty and experts.", }, { @@ -197,7 +201,7 @@ export const continuum = { }, { eyebrow: "03 . SPECIALIZATION", - body: "Employ expertise for impact at-scale.", + body: "Employ expertise for impact at scale.", }, ] satisfies ContinuumStepItem[], } diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 588b8d1295..e8fcc9e7a8 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -67,7 +67,7 @@ "typescript": "^5.5.4" }, "peerDependencies": { - "@mitodl/smoot-design": "6.36.0", + "@mitodl/smoot-design": "6.38.0", "next": "^16.2.7" } } diff --git a/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx b/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx index 05e4a7ddfc..74c49ba656 100644 --- a/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx +++ b/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx @@ -2,10 +2,18 @@ import React from "react" import GoogleReCAPTCHA from "react-google-recaptcha" import styled from "@emotion/styled" +const RECAPTCHA_MARGIN_VAR = "--recaptcha-margin" + const ReCaptchaContainer = styled.div({ display: "flex", justifyContent: "flex-start", - margin: "12px 0", + /** + * Reads from a CSS custom property so a consumer whose own layout already + * provides spacing (e.g. a flex form with a `gap`) can zero this out for + * just its subtree without needing a selector into this component's + * internals: `[RECAPTCHA_MARGIN_VAR]: 0` on any ancestor. + */ + margin: `var(${RECAPTCHA_MARGIN_VAR}, 12px 0)`, "& .g_id_signIn": { maxWidth: "100%", }, @@ -61,5 +69,5 @@ const ReCaptcha = ({ ) } -export { ReCaptcha } +export { ReCaptcha, RECAPTCHA_MARGIN_VAR } export type { ReCaptchaProps } diff --git a/frontends/ol-components/src/components/ReCaptcha/index.ts b/frontends/ol-components/src/components/ReCaptcha/index.ts index 966199d698..7df56bc967 100644 --- a/frontends/ol-components/src/components/ReCaptcha/index.ts +++ b/frontends/ol-components/src/components/ReCaptcha/index.ts @@ -1,2 +1,2 @@ -export { ReCaptcha } from "./ReCaptcha" +export { ReCaptcha, RECAPTCHA_MARGIN_VAR } from "./ReCaptcha" export type { ReCaptchaProps } from "./ReCaptcha" diff --git a/yarn.lock b/yarn.lock index cd179936a1..e4c62c5dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3641,9 +3641,9 @@ __metadata: languageName: node linkType: hard -"@mitodl/smoot-design@npm:6.36.0": - version: 6.36.0 - resolution: "@mitodl/smoot-design@npm:6.36.0" +"@mitodl/smoot-design@npm:6.38.0": + version: 6.38.0 + resolution: "@mitodl/smoot-design@npm:6.38.0" dependencies: "@ai-sdk/react": "npm:1.2.12" "@emotion/cache": "npm:^11.14.0" @@ -3668,7 +3668,7 @@ __metadata: "@remixicon/react": ^4.2.0 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: 10/515bff3758b289e9f72064608a65c2914f557f034b02b3fc289b811f25bd62972ac8c50a2b62d6a3f54ba41273baebd3eff9a28db173225d375410a4850b12ea + checksum: 10/506da4b1e7b9d84a8e5911b1399ba47edfcd28f56e54ccaf59960d8cbaf24410995ed3a41a69ec43b62e49f80dd80d786fe07e47e4bae3b84237e789502fecc4 languageName: node linkType: hard @@ -16919,7 +16919,7 @@ __metadata: "@mitodl/course-search-utils": "npm:^3.8.1" "@mitodl/hacksnack": "npm:^0.1.2" "@mitodl/mitxonline-api-axios": "npm:2026.9.21" - "@mitodl/smoot-design": "npm:6.36.0" + "@mitodl/smoot-design": "npm:6.38.0" "@mui/base": "npm:5.0.0-beta.70" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" @@ -18572,7 +18572,7 @@ __metadata: typescript: "npm:^5.5.4" wheel-indicator: "npm:^1.3.0" peerDependencies: - "@mitodl/smoot-design": 6.36.0 + "@mitodl/smoot-design": 6.38.0 next: ^16.2.7 languageName: unknown linkType: soft From 51ed8900c0f968a05ed5cbf95d03bbe5bbbe1ba8 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 23 Sep 2026 18:35:58 +0000 Subject: [PATCH 4/4] Release 0.80.16 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index da572a6b2b..71590bf66d 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.80.16 +--------------- + +- fix: align Organizational Learning page with Figma design QA (#3971) +- feat: add hover explanation for Active learners, fix clipped month label in engagement chart (#3966) +- Track begin-checkout analytics for enrollments (#3959) + Version 0.80.15 --------------- diff --git a/main/settings.py b/main/settings.py index 731bce5ca2..c2e8533d40 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.15" +VERSION = "0.80.16" log = logging.getLogger()