From 3fd045836a3b1f9bc160b8ef65c86fbc1278bce2 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Mon, 31 Aug 2026 18:14:37 -0400 Subject: [PATCH 1/4] Clear the a11y findings eslint-plugin-styled-components-a11y 2.2 adds (#3868) * Clear the a11y findings eslint-plugin-styled-components-a11y 2.2 adds Two are real: the contract admin table had an unlabelled columnheader, and the user menu declared role="button" on a styled.button. The other seven are deliberate patterns the rules cannot see through, so they get a scoped disable with the reason: - Card and the video-shorts player forward a body click to a control the user can already reach by keyboard (the card's own anchor, the visible PlayPauseButton). A keydown handler would duplicate it. - The video-shorts slide is the APG carousel pattern: role="group" with a roving tabindex and Enter to toggle playback. - NavDrawer is a persistent Drawer, so it is not a MUI Modal and gets no built-in Escape handling; the onKeyUp is the only keyboard exit. - The suppressed-value marker is focusable so its tooltip is reachable without a pointer; there is no action to justify a button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FyJ72HRTyQf6jVYmw3GZSt * Correct the NavDrawer suppression rationale The comment claimed the Escape listener was the only keyboard way out of the drawer. It is not: the Close Navigation button at NavDrawer.tsx:204 is the primary exit, and this listener adds the Escape shortcut on top of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FyJ72HRTyQf6jVYmw3GZSt * Narrow the Card suppression rationale to its actual precondition The comments claimed the container click always forwards to the card's anchor. It only does when forwardClicksToLink is set, and that defaults to false (Card.tsx:321) -- in the other mode handleClick is the raw onClick and the container is pointer-only. Every caller today pairs the two (BaseLearningResourceCard), but nothing enforces it and Card is an ol-components export. Comment says so now; #3874 tracks making it impossible to get wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FyJ72HRTyQf6jVYmw3GZSt --------- Co-authored-by: Claude Opus 5 --- .../ContractAdminPage/ContractAdminPage.tsx | 2 +- .../src/app-pages/DashboardPage/Analytics/format.tsx | 6 ++++++ .../main/src/app-pages/HomePage/VideoShortsModal.tsx | 12 ++++++++++++ .../main/src/page-components/Header/UserMenu.tsx | 2 +- frontends/ol-components/src/components/Card/Card.tsx | 2 ++ .../src/components/NavDrawer/NavDrawer.tsx | 8 ++++++++ 6 files changed, 30 insertions(+), 2 deletions(-) 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/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 */ = ({ 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") { From 6fda159b8072739b4cee58881bc5b981986859aa Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 1 Sep 2026 09:07:47 -0400 Subject: [PATCH 2/4] Unify actions/checkout on v7.0.1 (#3866) actions-static-analysis.yml was already on v7.0.1 while the other three workflows sat on v6.0.1, so Renovate had two pins to chase for one action. Neither v7 breaking change applies here: the fork-checkout block only fires on pull_request_target and workflow_run, and none of these workflows use either trigger. Claude-Session: https://claude.ai/code/session_01FyJ72HRTyQf6jVYmw3GZSt Co-authored-by: Claude Opus 5 --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/openapi-diff.yml | 4 ++-- .github/workflows/publish-pages.yml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) 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 From 357238e6de3c63fd7a6dd5847c0343c762b043c7 Mon Sep 17 00:00:00 2001 From: Dan Subak Date: Tue, 1 Sep 2026 10:21:24 -0400 Subject: [PATCH 3/4] Checkout completed and account created GTM events (#3795) --- authentication/views.py | 4 +- authentication/views_test.py | 5 +- .../EnrollmentRedirectAlert.test.tsx | 56 +++++++++++++++++++ .../DashboardPage/EnrollmentRedirectAlert.tsx | 19 +++++++ .../OnboardingPage/OnboardingPage.test.tsx | 32 +++++++++++ .../OnboardingPage/OnboardingPage.tsx | 23 ++++++++ .../main/src/common/analytics/gtm.test.ts | 41 ++++++++++++++ frontends/main/src/common/analytics/gtm.ts | 39 ++++++++++++- frontends/main/src/common/searchParams.ts | 1 + 9 files changed, 217 insertions(+), 3 deletions(-) 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/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/OnboardingPage/OnboardingPage.test.tsx b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx index a0120db7e3..6b4098acd9 100644 --- a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx +++ b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx @@ -21,6 +21,7 @@ import { import OnboardingPage from "./OnboardingPage" import { usePostHog } from "posthog-js/react" import { PostHogEvents } from "@/common/constants" +import { trackAccountCreated } from "@/common/analytics/gtm" jest.mock("posthog-js/react", () => ({ ...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] From 9e9e45ca32a1370d0dcce30367b708f4cba49015 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Sep 2026 14:23:33 +0000 Subject: [PATCH 4/4] Release 0.78.2 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/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()