diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c25f3df688..d2be38a284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: - 8030:8030 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -107,7 +107,7 @@ jobs: javascript-tests: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -158,7 +158,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -175,7 +175,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -201,7 +201,7 @@ jobs: GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v0 runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -242,7 +242,7 @@ jobs: GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v1 runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index b54ff415d3..2f66b59955 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -8,13 +8,13 @@ jobs: pull-requests: write steps: - name: Checkout HEAD - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.head_ref }} path: head persist-credentials: false - name: Checkout BASE - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.base_ref }} path: base diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml index fb86ab697a..c3ad9d6b44 100644 --- a/.github/workflows/publish-pages.yml +++ b/.github/workflows/publish-pages.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/RELEASE.rst b/RELEASE.rst index 3e521bfa05..71172a2af3 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.78.2 +-------------- + +- Checkout completed and account created GTM events (#3795) +- Unify actions/checkout on v7.0.1 (#3866) +- Clear the a11y findings eslint-plugin-styled-components-a11y 2.2 adds (#3868) + Version 0.78.1 (Released September 01, 2026) -------------- diff --git a/authentication/views.py b/authentication/views.py index e34a3e35c3..102d48f4c3 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -111,7 +111,9 @@ def get( if should_skip_onboarding: redirect_url = signup_redirect_url else: - params = urlencode({"next": signup_redirect_url}) + params = urlencode( + {"next": signup_redirect_url, "is_new_user": "1"} + ) redirect_url = f"{settings.MITOL_NEW_USER_LOGIN_URL}?{params}" profile.save() diff --git a/authentication/views_test.py b/authentication/views_test.py index 0476a2a3e0..4dbdf98375 100644 --- a/authentication/views_test.py +++ b/authentication/views_test.py @@ -188,7 +188,10 @@ def test_custom_login_view_authenticated_user_needs_onboarding( assert response.status_code == 302 if expect_onboarding: - assert response.url == f"/onboarding?{urlencode({'next': expected_redirect})}" + assert ( + response.url + == f"/onboarding?{urlencode({'next': expected_redirect, 'is_new_user': '1'})}" + ) else: assert response.url == expected_redirect mock_send_welcome_email.assert_called_once_with(request.user.id) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 1b813dd88e..15f5e43f34 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -883,7 +883,7 @@ const ContractAdminPageInternal: React.FC = ({ > Last sent - + diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx index 741ab281cf..8a008617d6 100644 --- a/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx @@ -99,6 +99,12 @@ const SuppressedMark = styled.span(({ theme }) => ({ */ const Suppressed: React.FC = () => ( + {/* + Focusable on purpose: the tooltip is the only way a sighted keyboard user + reaches the explanation, and there is no action here to justify a button. + Screen readers get it from aria-label without focusing. + */} + {/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */} diff --git a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.test.tsx b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.test.tsx index 52f95f30f2..02ef896d3c 100644 --- a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.test.tsx @@ -9,6 +9,11 @@ import { import EnrollmentRedirectAlert from "./EnrollmentRedirectAlert" import { DASHBOARD_MY_LEARNING } from "@/common/urls" import * as mitxonline from "api/mitxonline-test-utils" +import { trackCheckoutCompleted } from "@/common/analytics/gtm" + +jest.mock("@/common/analytics/gtm", () => ({ + trackCheckoutCompleted: jest.fn(), +})) const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -249,6 +254,57 @@ describe("EnrollmentRedirectAlert", () => { ).toBeInTheDocument() }) + test("tracks checkout-completed once with order id, course name, and value from receipt", async () => { + const receipt = mitxonline.factories.orders.order({ + lines: [mitxonline.factories.orders.transactionLine()], + total_price_paid: "199.99", + }) + + setMockResponse.get(mitxonline.urls.orders.receipt(17), receipt) + + renderWithProviders(, { + url: "/dashboard?order_status=fulfilled&order_id=17", + }) + + await screen.findByRole("alert") + + expect(trackCheckoutCompleted).toHaveBeenCalledTimes(1) + expect(trackCheckoutCompleted).toHaveBeenCalledWith({ + orderId: 17, + courseName: receipt.lines[0].content_title, + value: 199.99, + }) + }) + + test("tracks checkout-completed with a null value when the receipt fails to load", async () => { + setMockResponse.get(mitxonline.urls.orders.receipt(18), "Server error", { + code: 500, + }) + + renderWithProviders(, { + url: "/dashboard?order_status=fulfilled&order_id=18", + }) + + await screen.findByRole("alert") + + expect(trackCheckoutCompleted).toHaveBeenCalledTimes(1) + expect(trackCheckoutCompleted).toHaveBeenCalledWith({ + orderId: 18, + courseName: undefined, + value: null, + }) + }) + + test("does not track checkout-completed for non-paid alerts", async () => { + renderWithProviders(, { + url: "/dashboard?enrollment_status=success&enrollment_title=Data+Science", + }) + + await screen.findByRole("alert") + + expect(trackCheckoutCompleted).not.toHaveBeenCalled() + }) + test.each([ { label: "non-numeric", diff --git a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx index 7fd8e9c775..d76b55db3c 100644 --- a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx +++ b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx @@ -8,6 +8,7 @@ import { Link, Skeleton, styled } from "ol-components" import { orderQueries } from "api/mitxonline-hooks/orders" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { DASHBOARD_MY_LEARNING } from "@/common/urls" +import { trackCheckoutCompleted } from "@/common/analytics/gtm" import { ENROLLMENT_STATUS_PARAM, ENROLLMENT_ERROR_TYPE_PARAM, @@ -183,6 +184,24 @@ const EnrollmentRedirectAlert: React.FC = () => { enabled: request?.kind === "paid", }) + const checkoutCompletedTracked = React.useRef(false) + React.useEffect(() => { + if (request?.kind !== "paid") return + if (paidReceipt.isPending) return + if (checkoutCompletedTracked.current) return + checkoutCompletedTracked.current = true + + const parsedValue = paidReceipt.data + ? Number(paidReceipt.data.total_price_paid) + : NaN + + trackCheckoutCompleted({ + orderId: request.orderId, + courseName: paidReceipt.data?.lines[0]?.content_title, + value: Number.isNaN(parsedValue) ? null : parsedValue, + }) + }, [request, paidReceipt.isPending, paidReceipt.data]) + if (request?.kind === "error") { const errorMessage = request.errorType === EnrollmentErrorType.INVALID_ENROLLMENT_CODE diff --git a/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx b/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx index 98e2019a48..5386d74ce8 100644 --- a/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx +++ b/frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx @@ -218,6 +218,12 @@ const VideoWithErrorHandler = ({ if (!src) return null return ( + /* + Tap-anywhere play/pause is a pointer convenience. The same toggle is on + the visible PlayPauseButton and on the slide's Enter handler, so keyboard + users already have it; a handler here would only duplicate them. + */ + /* eslint-disable-next-line styled-components-a11y/click-events-have-key-events, styled-components-a11y/no-static-element-interactions */ {videoData?.map((video: VideoResource, index: number) => ( + /* + The APG carousel pattern: role="group" + aria-roledescription + with a roving tabindex. Enter toggles playback, matching the tap + target the slide already exposes. + */ + /* eslint-disable-next-line styled-components-a11y/no-noninteractive-element-interactions */ ({ ...jest.requireActual("posthog-js/react"), @@ -32,6 +33,10 @@ jest.mocked(usePostHog).mockReturnValue( { capture: mockCapture }, ) +jest.mock("@/common/analytics/gtm", () => ({ + trackAccountCreated: jest.fn(), +})) + jest.mock("next/navigation", () => jest.requireActual("next-router-mock/navigation"), ) @@ -216,4 +221,31 @@ describe("OnboardingPage", () => { expect(mockCapture).not.toHaveBeenCalled() }) }) + + describe("GTM account-created tracking", () => { + beforeEach(() => { + jest.mocked(trackAccountCreated).mockClear() + sessionStorage.clear() + }) + + it("fires trackAccountCreated once the profile loads for a new user", async () => { + await setupAndProgressToStep(0, "/onboarding?is_new_user=1") + await waitFor(() => { + expect(trackAccountCreated).toHaveBeenCalledTimes(1) + }) + }) + + it("does not fire trackAccountCreated when the new param is absent", async () => { + await setupAndProgressToStep(0) + await findNextButton() + expect(trackAccountCreated).not.toHaveBeenCalled() + }) + + it("does not fire trackAccountCreated again within the same session", async () => { + sessionStorage.setItem("gtm_account_created_tracked", "1") + await setupAndProgressToStep(0, "/onboarding?is_new_user=1") + await findNextButton() + expect(trackAccountCreated).not.toHaveBeenCalled() + }) + }) }) diff --git a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx index 5ed32e1fa3..c4d21e66c3 100644 --- a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx +++ b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx @@ -36,8 +36,10 @@ import { } from "@/common/profile" import { useAppSearchParams } from "@/common/useAppSearchParams" import { PostHogEvents } from "@/common/constants" +import { trackAccountCreated } from "@/common/analytics/gtm" const NUM_STEPS = 5 +const ACCOUNT_CREATED_SESSION_KEY = "gtm_account_created_tracked" const FlexContainer = styled(Container)({ display: "flex", @@ -161,6 +163,7 @@ const OnboardingPage: React.FC = () => { const posthog = usePostHog() const searchParams = useAppSearchParams() const nextUrl = searchParams.get("next") + const isNewUser = searchParams.get("is_new_user") === "1" const formik = useFormik({ enableReinitialize: true, @@ -201,6 +204,26 @@ const OnboardingPage: React.FC = () => { } }, [nextUrl, router]) + useEffect(() => { + if (!profile || !isNewUser) return + + let alreadyTracked = false + try { + alreadyTracked = Boolean( + sessionStorage.getItem(ACCOUNT_CREATED_SESSION_KEY), + ) + if (!alreadyTracked) { + sessionStorage.setItem(ACCOUNT_CREATED_SESSION_KEY, "1") + } + } catch { + // Storage may be unavailable; fall back to tracking without persistence. + } + + if (!alreadyTracked) { + trackAccountCreated() + } + }, [profile, isNewUser]) + const handleBack = () => { setActiveStep((prevActiveStep) => prevActiveStep - 1) } diff --git a/frontends/main/src/common/analytics/gtm.test.ts b/frontends/main/src/common/analytics/gtm.test.ts index 7f75742834..fb966e120d 100644 --- a/frontends/main/src/common/analytics/gtm.test.ts +++ b/frontends/main/src/common/analytics/gtm.test.ts @@ -15,6 +15,8 @@ import { trackFilterCourseCatalog, trackReturnVisit, trackBeginCheckout, + trackCheckoutCompleted, + trackAccountCreated, trackOrganicSocialClick, trackViewProgramDetails, } from "./gtm" @@ -294,6 +296,45 @@ describe("trackBeginCheckout", () => { }) }) +describe("trackCheckoutCompleted", () => { + it("pushes a checkout-completed event with all fields", () => { + trackCheckoutCompleted({ + orderId: 17, + courseName: "Data Science Fundamentals", + value: 199.99, + }) + expect(window.dataLayer).toContainEqual({ + event: "checkout-completed", + "order-id": 17, + "course-name": "Data Science Fundamentals", + "order-value": 199.99, + }) + }) + + it("omits course-name and order-value when not provided", () => { + trackCheckoutCompleted({ orderId: 17 }) + expect(window.dataLayer).toContainEqual({ + event: "checkout-completed", + "order-id": 17, + }) + }) + + it("omits order-value when null", () => { + trackCheckoutCompleted({ orderId: 17, value: null }) + expect(window.dataLayer).toContainEqual({ + event: "checkout-completed", + "order-id": 17, + }) + }) +}) + +describe("trackAccountCreated", () => { + it("pushes an account-created event", () => { + trackAccountCreated() + expect(window.dataLayer).toContainEqual({ event: "account-created" }) + }) +}) + describe("trackOrganicSocialClick", () => { it("pushes an organic-social-click event with platform", () => { trackOrganicSocialClick("Facebook") diff --git a/frontends/main/src/common/analytics/gtm.ts b/frontends/main/src/common/analytics/gtm.ts index 04283a7131..5b1f07c6f5 100644 --- a/frontends/main/src/common/analytics/gtm.ts +++ b/frontends/main/src/common/analytics/gtm.ts @@ -231,6 +231,36 @@ const trackBeginCheckout = (courseName?: string | null) => { }) } +type CheckoutCompletedParams = { + orderId: number + courseName?: string | null + value?: number | null +} + +/** + * Fired when a user returns from the external checkout flow with a + * confirmed-fulfilled paid order. The completion counterpart to + * trackBeginCheckout — maps to "Checkout Completed" in the marketing event + * plan. + */ +const trackCheckoutCompleted = (params: CheckoutCompletedParams) => { + pushGtmEvent("checkout-completed", { + "order-id": params.orderId, + ...(params.courseName ? { "course-name": params.courseName } : {}), + ...(params.value !== null && params.value !== undefined + ? { "order-value": params.value } + : {}), + }) +} + +/** + * Fired once, the first time a newly created account reaches the onboarding + * flow. Maps to "Account Created" in the marketing event plan. + */ +const trackAccountCreated = () => { + pushGtmEvent("account-created") +} + /** * Fired when a user clicks an organic social share link (Facebook, Twitter, LinkedIn). * Maps to "Click on Organic Social Post" in the marketing event plan. @@ -273,8 +303,15 @@ export { trackFilterCourseCatalog, trackReturnVisit, trackBeginCheckout, + trackCheckoutCompleted, + trackAccountCreated, trackOrganicSocialClick, trackViewProgramDetails, } -export type { AddToCartParams, CatalogFilterParams, CourseProgramViewParams } +export type { + AddToCartParams, + CatalogFilterParams, + CourseProgramViewParams, + CheckoutCompletedParams, +} diff --git a/frontends/main/src/common/searchParams.ts b/frontends/main/src/common/searchParams.ts index eab4b1d290..0a2f4aea48 100644 --- a/frontends/main/src/common/searchParams.ts +++ b/frontends/main/src/common/searchParams.ts @@ -97,6 +97,7 @@ const SERVER_KEYED_PARAMS = [ "order_id", "account_action", "account_action_status", + "is_new_user", ] as const type ServerSearchParam = (typeof SERVER_KEYED_PARAMS)[number] diff --git a/frontends/main/src/page-components/Header/UserMenu.tsx b/frontends/main/src/page-components/Header/UserMenu.tsx index fa0a558695..60a57fc2d5 100644 --- a/frontends/main/src/page-components/Header/UserMenu.tsx +++ b/frontends/main/src/page-components/Header/UserMenu.tsx @@ -216,7 +216,7 @@ const UserMenu: React.FC = ({ variant }) => { .filter(({ allow }) => allow) .map(({ allow, ...item }) => item)} trigger={ - + {user?.is_authenticated ? : ""} diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx index 479cc27fc9..4c26d569d4 100644 --- a/frontends/ol-components/src/components/Card/Card.tsx +++ b/frontends/ol-components/src/components/Card/Card.tsx @@ -360,6 +360,7 @@ const Card: Card = ({ if (content) { return ( + // eslint-disable-next-line styled-components-a11y/click-events-have-key-events -- sound only while callers pair onClick with forwardClicksToLink, as BaseLearningResourceCard does: the click then forwards to the card's own anchor (useClickChildLink), which keyboard users activate directly. Unenforced -- see #3874 + {/* + A persistent Drawer is not a MUI Modal, so it gets no built-in + Escape handling. The Close Navigation button above is the + primary keyboard exit; this listener adds the Escape shortcut a + user expects from a drawer. The panel is a focus-trapped region + rather than a control, so there is no interactive role for it. + */} + {/* eslint-disable-next-line styled-components-a11y/no-static-element-interactions */} { if (e.key === "Escape") { diff --git a/main/settings.py b/main/settings.py index cdc5ef75ee..efb02e5640 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.78.1" +VERSION = "0.78.2" log = logging.getLogger()