diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx index 568f3b26f4..ad08293b7d 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx @@ -103,6 +103,7 @@ const CourseEnrollArea: React.FC = ({ const { state, scenario, isStatusLoading, isPending, isError } = useCourseEnrollment(course, selectedRun, { + tracking: { placement: "infobox" }, onRequireSignup: (el) => setAnchor(el), }) diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx index 33f4166191..1b83783faa 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx @@ -66,7 +66,10 @@ const CourseHeaderEnrollButton: React.FC<{ const { state, isStatusLoading, isPending } = useCourseEnrollment( course, selectedRun, - { onRequireSignup: (el) => setAnchor(el) }, + { + tracking: { placement: "header" }, + onRequireSignup: (el) => setAnchor(el), + }, ) if (state.status === "enrolled") { diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 26a6ed190e..70641bc629 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -510,7 +510,11 @@ describe("useCourseEnrollment — actions", () => { const onRequireSignup = jest.fn() const { result } = renderHook( - () => useCourseEnrollment(course, run, { onRequireSignup }), + () => + useCourseEnrollment(course, run, { + tracking: { placement: "infobox" }, + onRequireSignup, + }), { wrapper }, ) @@ -533,7 +537,7 @@ describe("useCourseEnrollment — actions", () => { expect(onRequireSignup).toHaveBeenCalledWith(anchorButton) }) - test("fires PostHog cta_clicked on paid action click", async () => { + test("fires PostHog enroll_cta_clicked on paid action click", async () => { const product = makeProduct() const run = makeRun({ is_enrollable: true, @@ -550,9 +554,13 @@ describe("useCourseEnrollment — actions", () => { items: [], }) - const { result } = renderHook(() => useCourseEnrollment(course, run), { - wrapper, - }) + const { result } = renderHook( + () => + useCourseEnrollment(course, run, { + tracking: { placement: "infobox" }, + }), + { wrapper }, + ) await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) @@ -567,10 +575,54 @@ describe("useCourseEnrollment — actions", () => { paidOption.onClick!(fakeEvent) expect(mockCapture).toHaveBeenCalledWith( - PostHogEvents.CallToActionClicked, + PostHogEvents.EnrollCtaClicked, expect.objectContaining({ + placement: "infobox", + enrollmentMode: "verified", + resourceType: "course", readableId: course.readable_id, + }), + ) + }) + + test("fires PostHog enroll_cta_clicked with audit mode on free action click", async () => { + // Unauthenticated: the event fires before the auth gate, then we return to + // the signup flow — so the fire is observable without an enrollment mock. + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + const run = makeRun({ + is_enrollable: true, + is_upgradable: false, + is_archived: false, + enrollment_modes: [makeMode({ requires_payment: false })], + }) + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + + const { result } = renderHook( + () => + useCourseEnrollment(course, run, { tracking: { placement: "header" } }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isStatusLoading).toBe(false)) + + const state = result.current.state + if (state.status !== "options") return + const freeOption = state.options.find((o) => o.kind === "free")! + + freeOption.onClick!({ + currentTarget: document.createElement("button"), + } as React.MouseEvent) + + expect(mockCapture).toHaveBeenCalledWith( + PostHogEvents.EnrollCtaClicked, + expect.objectContaining({ + placement: "header", + enrollmentMode: "audit", resourceType: "course", + readableId: course.readable_id, }), ) }) diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index d1783d09fd..5ce911b0fd 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -4,6 +4,7 @@ import type { CourseRunV2, CourseWithCourseRunsSerializerV2, } from "@mitodl/mitxonline-api-axios/v2" +import { PlatformEnum } from "api" import { userQueries } from "api/hooks/user" import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem" @@ -42,10 +43,17 @@ export type UseCourseEnrollment = { isError: boolean } +type UseCourseEnrollmentOptions = { + /** Analytics-only metadata for the `enroll_cta_clicked` event; no behavior. */ + tracking: { placement: "header" | "infobox" } + /** Behavioral: called when an unauthenticated user clicks an enroll action. */ + onRequireSignup?: (anchor: HTMLButtonElement) => void +} + export const useCourseEnrollment = ( course: CourseWithCourseRunsSerializerV2, selectedRun: CourseRunV2 | undefined, - opts?: { onRequireSignup?: (anchor: HTMLButtonElement) => void }, + opts?: UseCourseEnrollmentOptions, ): UseCourseEnrollment => { const me = useQuery({ ...userQueries.me(), @@ -77,11 +85,18 @@ export const useCourseEnrollment = ( // a misleading "problem processing your enrollment" message. const isError = replaceBasketItem.isError || createEnrollment.isError - const firePostHog = (label: string) => { + // Dedicated, semantically-named enrollment event (hq#11941). Replaces the + // generic cta_clicked for enroll CTAs so analytics can slice by placement / + // enrollment_mode instead of the drifting button copy. `label` is retained + // for human readability, not as the analytics key. + const firePostHog = (kind: EnrollActionKind, label: string) => { if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { - posthog.capture(PostHogEvents.CallToActionClicked, { - readableId: course.readable_id, + posthog.capture(PostHogEvents.EnrollCtaClicked, { + placement: opts?.tracking.placement, + enrollmentMode: kind === "paid" ? "verified" : "audit", resourceType: "course", + readableId: course.readable_id, + platform: PlatformEnum.Mitxonline, label, }) } @@ -90,7 +105,7 @@ export const useCourseEnrollment = ( const makeOnClick = (kind: EnrollActionKind, label: string): EnrollAction["onClick"] => (e) => { - firePostHog(label) + firePostHog(kind, label) if (!me.data?.is_authenticated) { opts?.onRequireSignup?.(e.currentTarget) return diff --git a/frontends/main/src/common/constants.ts b/frontends/main/src/common/constants.ts index c60c7c32d7..a5a414856e 100644 --- a/frontends/main/src/common/constants.ts +++ b/frontends/main/src/common/constants.ts @@ -1,6 +1,7 @@ export const PostHogEvents = { SearchUpdate: "search_update", CallToActionClicked: "cta_clicked", + EnrollCtaClicked: "enroll_cta_clicked", CourseCardClicked: "course_card_clicked", AskTimClicked: "asktim_clicked", LearningResourceDrawerOpen: "lrd_open",