Skip to content

Commit 7375d41

Browse files
Add posthog enrollment_cta_clicked event with enrollment-specific data (#3542)
* feat(cta-tracking): emit dedicated enroll_cta_clicked event (hq#11941) Course enroll CTAs fired the generic `cta_clicked` event keyed on the visible button copy, so analytics insights broke whenever labels changed (hq#11941) — and the infobox redesign changes them again. Emit a dedicated `enroll_cta_clicked` event from the course enroll handlers (header + infobox) with structured, copy-independent properties: - placement: "header" | "infobox" - enrollment_mode: "verified" | "audit" - resource_type, readable_id (+ label retained for humans, not as the key) Placement is threaded via a new useCourseEnrollment opt set by each call site. Generic cta_clicked is unchanged elsewhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harden(cta-tracking): require placement, add platform, camelCase props Review follow-ups on the dedicated enroll_cta_clicked event: - Make `placement` required in the useCourseEnrollment opts so a future call site can't silently emit `placement: undefined` (the same silent-drop class hq#11941 fixes). Both existing call sites already pass it. - Add `platform: PlatformEnum.Mitxonline` for parity with sibling cta events (constant on these product pages; was not on the old course-enroll event). - Use camelCase property keys (enrollmentMode/resourceType/readableId) to match the repo's established convention across all ~15 capture sites, so `readableId` lines up with the legacy cta_clicked for a clean cross-event breakdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(cta-tracking): group analytics-only opts under `tracking` `useCourseEnrollment`'s opts mixed analytics metadata (`placement`, used only for the enroll_cta_clicked event) with behavior (`onRequireSignup`). Group the analytics-only field under a `tracking` key so the two intents are structurally separated: useCourseEnrollment(course, run, { tracking: { placement: "infobox" }, // analytics-only onRequireSignup, // behavioral }) No behavior change. Checked the sibling handlers — ProgramEnrollmentButton and the other capture sites fire inline without a mixed opts object, so none need an analogous change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 08c026a commit 7375d41

5 files changed

Lines changed: 84 additions & 12 deletions

File tree

frontends/main/src/app-pages/ProductPages/CourseEnrollArea.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ const CourseEnrollArea: React.FC<CourseEnrollAreaProps> = ({
103103

104104
const { state, scenario, isStatusLoading, isPending, isError } =
105105
useCourseEnrollment(course, selectedRun, {
106+
tracking: { placement: "infobox" },
106107
onRequireSignup: (el) => setAnchor(el),
107108
})
108109

frontends/main/src/app-pages/ProductPages/CoursePage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ const CourseHeaderEnrollButton: React.FC<{
6666
const { state, isStatusLoading, isPending } = useCourseEnrollment(
6767
course,
6868
selectedRun,
69-
{ onRequireSignup: (el) => setAnchor(el) },
69+
{
70+
tracking: { placement: "header" },
71+
onRequireSignup: (el) => setAnchor(el),
72+
},
7073
)
7174

7275
if (state.status === "enrolled") {

frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,11 @@ describe("useCourseEnrollment — actions", () => {
510510
const onRequireSignup = jest.fn()
511511

512512
const { result } = renderHook(
513-
() => useCourseEnrollment(course, run, { onRequireSignup }),
513+
() =>
514+
useCourseEnrollment(course, run, {
515+
tracking: { placement: "infobox" },
516+
onRequireSignup,
517+
}),
514518
{ wrapper },
515519
)
516520

@@ -533,7 +537,7 @@ describe("useCourseEnrollment — actions", () => {
533537
expect(onRequireSignup).toHaveBeenCalledWith(anchorButton)
534538
})
535539

536-
test("fires PostHog cta_clicked on paid action click", async () => {
540+
test("fires PostHog enroll_cta_clicked on paid action click", async () => {
537541
const product = makeProduct()
538542
const run = makeRun({
539543
is_enrollable: true,
@@ -550,9 +554,13 @@ describe("useCourseEnrollment — actions", () => {
550554
items: [],
551555
})
552556

553-
const { result } = renderHook(() => useCourseEnrollment(course, run), {
554-
wrapper,
555-
})
557+
const { result } = renderHook(
558+
() =>
559+
useCourseEnrollment(course, run, {
560+
tracking: { placement: "infobox" },
561+
}),
562+
{ wrapper },
563+
)
556564

557565
await waitFor(() => expect(result.current.isStatusLoading).toBe(false))
558566

@@ -567,10 +575,54 @@ describe("useCourseEnrollment — actions", () => {
567575
paidOption.onClick!(fakeEvent)
568576

569577
expect(mockCapture).toHaveBeenCalledWith(
570-
PostHogEvents.CallToActionClicked,
578+
PostHogEvents.EnrollCtaClicked,
571579
expect.objectContaining({
580+
placement: "infobox",
581+
enrollmentMode: "verified",
582+
resourceType: "course",
572583
readableId: course.readable_id,
584+
}),
585+
)
586+
})
587+
588+
test("fires PostHog enroll_cta_clicked with audit mode on free action click", async () => {
589+
// Unauthenticated: the event fires before the auth gate, then we return to
590+
// the signup flow — so the fire is observable without an enrollment mock.
591+
setMockResponse.get(
592+
urls.userMe.get(),
593+
makeUser({ is_authenticated: false }),
594+
)
595+
const run = makeRun({
596+
is_enrollable: true,
597+
is_upgradable: false,
598+
is_archived: false,
599+
enrollment_modes: [makeMode({ requires_payment: false })],
600+
})
601+
const course = makeCourse({ next_run_id: run.id, courseruns: [run] })
602+
603+
const { result } = renderHook(
604+
() =>
605+
useCourseEnrollment(course, run, { tracking: { placement: "header" } }),
606+
{ wrapper },
607+
)
608+
609+
await waitFor(() => expect(result.current.isStatusLoading).toBe(false))
610+
611+
const state = result.current.state
612+
if (state.status !== "options") return
613+
const freeOption = state.options.find((o) => o.kind === "free")!
614+
615+
freeOption.onClick!({
616+
currentTarget: document.createElement("button"),
617+
} as React.MouseEvent<HTMLButtonElement>)
618+
619+
expect(mockCapture).toHaveBeenCalledWith(
620+
PostHogEvents.EnrollCtaClicked,
621+
expect.objectContaining({
622+
placement: "header",
623+
enrollmentMode: "audit",
573624
resourceType: "course",
625+
readableId: course.readable_id,
574626
}),
575627
)
576628
})

frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CourseRunV2,
55
CourseWithCourseRunsSerializerV2,
66
} from "@mitodl/mitxonline-api-axios/v2"
7+
import { PlatformEnum } from "api"
78
import { userQueries } from "api/hooks/user"
89
import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment"
910
import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem"
@@ -42,10 +43,17 @@ export type UseCourseEnrollment = {
4243
isError: boolean
4344
}
4445

46+
type UseCourseEnrollmentOptions = {
47+
/** Analytics-only metadata for the `enroll_cta_clicked` event; no behavior. */
48+
tracking: { placement: "header" | "infobox" }
49+
/** Behavioral: called when an unauthenticated user clicks an enroll action. */
50+
onRequireSignup?: (anchor: HTMLButtonElement) => void
51+
}
52+
4553
export const useCourseEnrollment = (
4654
course: CourseWithCourseRunsSerializerV2,
4755
selectedRun: CourseRunV2 | undefined,
48-
opts?: { onRequireSignup?: (anchor: HTMLButtonElement) => void },
56+
opts?: UseCourseEnrollmentOptions,
4957
): UseCourseEnrollment => {
5058
const me = useQuery({
5159
...userQueries.me(),
@@ -77,11 +85,18 @@ export const useCourseEnrollment = (
7785
// a misleading "problem processing your enrollment" message.
7886
const isError = replaceBasketItem.isError || createEnrollment.isError
7987

80-
const firePostHog = (label: string) => {
88+
// Dedicated, semantically-named enrollment event (hq#11941). Replaces the
89+
// generic cta_clicked for enroll CTAs so analytics can slice by placement /
90+
// enrollment_mode instead of the drifting button copy. `label` is retained
91+
// for human readability, not as the analytics key.
92+
const firePostHog = (kind: EnrollActionKind, label: string) => {
8193
if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) {
82-
posthog.capture(PostHogEvents.CallToActionClicked, {
83-
readableId: course.readable_id,
94+
posthog.capture(PostHogEvents.EnrollCtaClicked, {
95+
placement: opts?.tracking.placement,
96+
enrollmentMode: kind === "paid" ? "verified" : "audit",
8497
resourceType: "course",
98+
readableId: course.readable_id,
99+
platform: PlatformEnum.Mitxonline,
85100
label,
86101
})
87102
}
@@ -90,7 +105,7 @@ export const useCourseEnrollment = (
90105
const makeOnClick =
91106
(kind: EnrollActionKind, label: string): EnrollAction["onClick"] =>
92107
(e) => {
93-
firePostHog(label)
108+
firePostHog(kind, label)
94109
if (!me.data?.is_authenticated) {
95110
opts?.onRequireSignup?.(e.currentTarget)
96111
return

frontends/main/src/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const PostHogEvents = {
22
SearchUpdate: "search_update",
33
CallToActionClicked: "cta_clicked",
4+
EnrollCtaClicked: "enroll_cta_clicked",
45
CourseCardClicked: "course_card_clicked",
56
AskTimClicked: "asktim_clicked",
67
LearningResourceDrawerOpen: "lrd_open",

0 commit comments

Comments
 (0)