Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/openapi-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
--------------

Expand Down
4 changes: 3 additions & 1 deletion authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion authentication/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
>
Last sent
</TableHeaderCell>
<ActionCell role="columnheader" />
<ActionCell role="columnheader" aria-label="Actions" />
</TableHeaderRow>
</div>
<TableBody role="rowgroup" $stale={isCodesRevalidating}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ const SuppressedMark = styled.span(({ theme }) => ({
*/
const Suppressed: React.FC = () => (
<Tooltip title={SUPPRESSED_EXPLANATION}>
{/*
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 */}
<SuppressedMark aria-label={SUPPRESSED_EXPLANATION} tabIndex={0}>
</SuppressedMark>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\$&")

Expand Down Expand Up @@ -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(<EnrollmentRedirectAlert />, {
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(<EnrollmentRedirectAlert />, {
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(<EnrollmentRedirectAlert />, {
url: "/dashboard?enrollment_status=success&enrollment_title=Data+Science",
})

await screen.findByRole("alert")

expect(trackCheckoutCompleted).not.toHaveBeenCalled()
})

test.each([
{
label: "non-numeric",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions frontends/main/src/app-pages/HomePage/VideoShortsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
<VideoPlayerContainer onClick={onVideoClick}>
<VideoJsPlayer
sources={sources}
Expand Down Expand Up @@ -501,6 +507,12 @@ const VideoShortsModal = ({
onSlidesInView={onSlidesInView}
>
{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 */
<CarouselSlide
key={video.id}
width={videoHeight * PORTRAIT_ASPECT_RATIO}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
)
Expand Down Expand Up @@ -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()
})
})
})
23 changes: 23 additions & 0 deletions frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading