Add course enrollment gtm#3524
Conversation
Add a small GTM helper (frontends/main/src/common/analytics/gtm.ts) that pushes events to window.dataLayer and export trackCourseEnrolled/trackCourseUnenrolled. Instrument enrollment and unenrollment success flows to send these events with the course/program title across dashboard, module, product, and enrollment dialog components (DashboardCard, ModuleCard, DashboardDialogs, CourseEnrollmentButton, ProgramEnrollmentButton, CourseEnrollmentDialog, ProgramEnrollmentDialog).
Add a site root page that redirects to /dashboard. Update GTM event payload for unenrollment to send "course-unenrolled-name" while keeping the old "course-enrolled-name" for backward compatibility. Enhance ConfiguredPostHogProvider with FeatureFlags import and LOCAL_FLAG_OVERRIDES so feature flags can be forced for local dev; merge overrides into bootstrap flags, provide a local POSTHOG_API_KEY fallback when overrides exist, and only initialize PostHog/provider when an API key or local overrides are present to enable local testing without a PostHog account.
for more information, see https://pre-commit.ci
…/mit-learn into add-course-enrollment-gtm
Introduce GTM/UTM analytics utilities and tests, and instrument UI to emit tracking events. Adds new analytics modules (common/analytics/gtm.ts, common/analytics/utm.ts) with helper functions (landing/ad arrival, course/program view, start enrollment, video start/50%, addToCart, site search, catalog filter, download asset, etc.) and corresponding unit tests. Wire tracking calls into multiple components: Certificate download, AboutSection expand, Course/Program pages view, enrollment flows (start, begin checkout, addToCart), StayUpdated modal sign-ups, video player play/50% hooks and VideoResourcePlayer, search field/site search, and catalog filter. Add AnalyticsTracker in SiteProviders to record first-session landing/ad/organic social/return-visit events. Exports types for analytics params. These changes enable richer marketing attribution and GTM data-layer events across the app.
Delete the trackDownloadAsset helper and its DownloadAssetParams type from common/analytics/gtm.ts, remove associated unit tests in gtm.test.ts, and remove the import/call from CertificatePage.tsx. Updates exports accordingly and cleans up unused analytics code and tests.
Resolved conflicts: - DashboardCard.tsx, ModuleCard.tsx: accepted main's deletion (dashboard refactor phase 7d moved this code to UnenrolledCourseCard and others, which already restores the trackCourseEnrolled call) - gtm.ts: kept our new tracking functions as a superset of main's version - CoursePage.tsx, ProgramPage.tsx: kept useEffect GTM tracking, dropped MitxOnlineProductPages feature flag guard (removed in main) - CourseEnrollmentButton.tsx, CourseEnrollmentDialog.tsx: kept our expanded GTM imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
for more information, see https://pre-commit.ci
…/mit-learn into add-course-enrollment-gtm
There was a problem hiding this comment.
Pull request overview
Adds additional Google Tag Manager (GTM) dataLayer events across key user actions (arrivals, search/filtering, product engagement, enrollment funnel, and video engagement), including new UTM parsing utilities to classify traffic sources.
Changes:
- Add
utmparsing + helpers to classify Google/LinkedIn ad traffic and organic social traffic. - Expand
gtmanalytics helpers and wire them into site-wide providers and multiple UI interactions (search, filters, enrollment, product detail interactions, video playback). - Add unit tests for the new
utmhelpers andgtmtracking functions.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| frontends/main/src/page-components/SearchField/SearchField.tsx | Fires site-search GTM event on search submission. |
| frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx | Fires catalog-filter GTM event when a filter is applied. |
| frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx | Fires addToCart GTM event when certificate upsell is selected. |
| frontends/main/src/common/analytics/utm.ts | Adds UTM parsing + traffic classification helpers (Google/LinkedIn/organic social). |
| frontends/main/src/common/analytics/utm.test.ts | Unit tests for UTM parsing and traffic classification. |
| frontends/main/src/common/analytics/gtm.ts | Adds many new GTM event helper functions and exports relevant param types. |
| frontends/main/src/common/analytics/gtm.test.ts | Unit tests validating GTM event payloads are pushed to window.dataLayer. |
| frontends/main/src/app/(site)/SiteProviders.tsx | Adds a client-side tracker to fire arrival/ad/return/social events on first session load. |
| frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoResourcePlayer.tsx | Wires video playback callbacks to GTM (video-start, video-50-percent). |
| frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoJsPlayer.tsx | Adds new onPlay/onHalfProgress props and registers video.js listeners. |
| frontends/main/src/app-pages/ProductPages/StayUpdatedModal.tsx | Fires sign-up-for-updates on successful HubSpot form submission. |
| frontends/main/src/app-pages/ProductPages/ProgramPage.tsx | Fires course-program-view when program data is available. |
| frontends/main/src/app-pages/ProductPages/CoursePage.tsx | Fires view-course-page + course-program-view when course data is available. |
| frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx | Fires start-enrollment and begin-checkout as user enters enrollment funnel. |
| frontends/main/src/app-pages/ProductPages/AboutSection.tsx | Fires view-program-details when About section is expanded. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
Looks reasonable to me - it kinda sucks that we have to carry around UTM parameters in session storage like this; feels like it should be more turnkey than this in 2026, but at least we don't have to mess with backend session storage. I'm gonna work through testing this afternoon, but it might be good to get a reviewer with more react experience in here as well! |
| let isReturnVisit = false | ||
|
|
||
| try { | ||
| alreadyTracked = Boolean(sessionStorage.getItem(SESSION_KEY)) |
There was a problem hiding this comment.
I am reasonably sure we do want to use sessionstorage, but given that we don't have a ton of clarity on what marketing might consider a "session" - is there any chance we ought to use localstorage?
The only argument I can really envision is if we didn't want to track one user with multiple tabs open as having multiple landing events, but since afaik localstorage info doesn't expire, it adds a bunch of addtl complications...
This might just be more of something we document and tell product + marketing about rather than ask for their opinions on at the moment. What do you think?
| * the Facebook add-to-cart and LinkedIn add-to-cart tags. | ||
| */ | ||
| const trackAddToCart = (params: AddToCartParams) => { | ||
| pushGtmEvent("addToCart", { |
There was a problem hiding this comment.
All our other events use dash-separated-lowercase as a naming convention. Should we change this to match?
Rebasing onto main pulled in the trackViewCoursePage/trackCourseProgramView useEffect (PR #3524). The CoursePage test's partial gtm mock only stubbed trackCourseEnrolled, so those two became undefined and the effect threw. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(course-infobox): selected-run + scenario helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): enrollment hooks (enroll-area state + enrolled run ids)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(course-infobox): preserve trackCourseEnrolled GTM event on free enroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): CertificateTrackCard
Adds a purely presentational paid-path card used in the Course InfoBox
redesign (PR1). Accepts an opaque price ReactNode, optional financialAid
link, productNoun for bullet copy, and optional embedded action node.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): LearnForFreeCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): SessionSelect
Presentational dropdown for selecting a course session (enrollable run).
Built on SimpleSelectField with accessible 'Session' label; converts
run ids at the boundary (String on options, Number in onChange). Shows
'Anytime' for self-paced past-start runs; appends '— Enrolled' for
runs the user is already enrolled in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): run-specific CourseSummary meta box + warnings
- CourseSummary now accepts selectedRun prop (CourseRunV2 | undefined)
instead of deriving the run from course.next_run_id internally
- Adds optional sessionSelect prop rendered in the dates row slot
- Payment deadline line extracted from CoursePriceRow into CourseSummary,
rendered beneath dates when selectedRun has upgrade_deadline and !is_archived
- Price/financial-assistance content removed from CourseSummary (now in cards)
- CoursePriceRow, CourseCertificateBox and their helpers deleted (file-local,
now unused after removing from CourseSummary)
- InfoBoxCourse: stopgap one-liner passes getSelectedRun(course) as selectedRun;
Task 7b will wire real selection state
Deleted test coverage:
- "Price Row" describe block: behavior moved to CertificateTrackCard (Task 3/4)
- "Financial Assistance" describe block: same — already covered by card tests
These deletions are intentional; coverage lives in CertificateTrackCard.test.tsx
Open item: no-runs Alert "Learn More" link — no destination URL defined in
codebase for the no-runs case; left without link pending design input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(course-infobox): CourseDatesRow honors selectedRun; drop dead PriceRow testid
Collapsed filter in CourseDatesRow was keying off course.next_run_id
instead of the passed-in nextRun prop, so selecting a non-default run
showed the wrong dates. Fixed to use nextRun.id.
TestIds.PriceRow is still live (used by ProgramPriceRow), so no removal.
Added divergence test: next_run_id != selectedRun → collapsed row shows
selectedRun's date.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): CourseEnrollArea + placement/size rules
* fix(course-infobox): wire financial-assistance link into Certificate Track card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): InfoBoxCourse responsive box grid + run selection
Wires InfoBoxCourse with selectedRunId state, useCourseEnrolledRunIds,
SessionSelect (multi-run only), CourseEnrollArea, and a BoxGrid styled
component that implements the count-aware responsive CSS grid (data-boxes
1|2|3 drives meta-span and single-column fallback at tablet breakpoint).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(course-infobox): wrap single-box offerings as one grid cell; span Choose Your Path heading
C1: paidOnly/freeOnly/deadlinePassed/archived scenarios now wrap their
card + below-button in a single div (data-card="paid"|"free"), making
each offering exactly one direct grid child. The both-case wrappers were
already correct.
I1: Add data-choose-path to the "Choose Your Path" heading in
CourseEnrollArea, then target it with grid-column: 1/-1 in InfoBoxCourse's
BoxGrid so it spans both columns in the 3-box (both) tablet layout.
Test: add structural assertion in InfoBoxCourse.test.tsx confirming
paidOnly grid has exactly 2 direct children and that the Enroll button
shares the same wrapper as the Certificate Track card.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(course-infobox): header mirrors recommended action; drop dialog from course page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(course-infobox): deterministic headings test (pin non-enrollable run)
The InfoBox now renders enroll-card h3 headings depending on the course
run's enrollability, which makeCourse() randomizes. Pin a non-enrollable
run so the page heading outline is deterministic while the outline
section (which needs a run with courseware_id) still renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(course-infobox): assert header basket-replace; drop dead enrolled popover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(course-infobox): suite green + cleanup
Delete orphaned CourseEnrollmentButton.tsx and its test file — no live importer remains after Tasks 7b and 8 rewired the Course page and InfoBox to use CourseEnrollArea/EnrollButton directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(course-infobox): access action enrolls→dashboard; deadline-passed label; review cleanups
- access kind in makeOnClick now takes the same audit-enroll + dashboard-redirect path as
free, fixing the silent no-op on "Access Course Materials" clicks (archived scenario)
- deadlinePassed option changed from kind:"free"/label:"Start Learning" to
kind:"access"/label:"Access Course Materials" for label consistency with archived
- useCourseEnrolledRunIds: remove dead ?? false on always-boolean isLoading/isError
- InfoBoxCourse: fix comment referencing .choose-path-heading → [data-choose-path]
- CertificateTrackCard: FeatureIcon styled via callback form (({ theme }) => ...) for
consistency with LearnForFreeCard; remove module-level theme import
- Tests: add archived-enroll regression guard; update deadlinePassed label assertions;
replace FIX4 no-op smoke tests with real assertions or removal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(course-infobox): review wave 2 — dead popover, EnrolledLink, robust placement tests, polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style(course-infobox): visual QA pass against Figma
Metadata: reorder Format → Estimated → Session; "Course Format" → "Format"
on the course row; payment deadline recolored to darkGray2/body3 and moved
into a 3-col SessionRow grid beneath the dropdown.
Session control: bare smoot Select + inline bold "Session:" label
(labelId→aria-labelledby), size=medium (40px), full-width.
Enroll area: "Choose Your Path" → subtitle2; full-width buttons; OfferingCell
gives a 16px card↔button gap; secondary variant only in the "both" case,
primary everywhere else.
Cards: financial-assistance moves into the feature list as a 4th bullet;
LearnForFreeCard border silverGrayLight → lightGray2.
Structure: lightGray2 <hr> divider between metadata and Choose-Your-Path
(hidden in tablet 2-col grid); header button text restored to darkGray2.
Tests updated for "Format" label and the new divider element.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(course-infobox): session gap, date-range year collapse, archived dates, header↔infobox run sync
- SessionSelect: bump label→dropdown gap to 16px (icon→label stays 8px) via
marginRight on the label; collapse the redundant start-year for same-year
date ranges ("Sep 8 - Dec 16, 2026"), keep both years across a year boundary.
- ProductSummary: archived single-run courses lead the dates row with
"Course content available anytime" + the end date instead of a stale start
date (gated to the single-date view; multi-date lists keep concrete dates).
- Lift selectedRunId to CoursePage so the header CTA tracks the session chosen
in the InfoBox. CourseInfoBox is now controlled (selectedRunId/onSelectRun);
the header receives the shared selectedRun.
Tests: same/cross-year range formatting, single-run archived dates row, and a
CoursePage correlation test (header CTA follows the InfoBox session switch).
InfoBoxCourse tests use a ControlledInfoBox wrapper for the lifted state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): deadline-passed/archived QA fixes + warning placement
Visual-QA fixes against the canonical Figma frames (A4/A5/A6):
- Deadline-passed (A4): add the yellow "Certificate deadline has passed."
alert; lead the date row with "Course content available anytime" + End;
suppress the payment-deadline line (no upcoming payment once the window
closed). Matches frame 39861:102467.
- Free card: the "Certificate deadline passed" note is now bold + underlined
and sits above the access bullet; show it for archived runs too (their
certificate window has also closed), not only deadline-passed.
- Warning placement: move the degraded-state notices (no-sessions, archived,
deadline-passed) to the bottom of the metadata block, just above the
offerings, matching Figma. They were rendered at the top — pre-existing
placement the spec never pinned.
Tests: update assertions for the new note string/placement; add a
deadline-passed describe block (alert / available-anytime date row /
suppressed payment deadline); pin the enrollment scenario in the date-row and
payment-deadline tests so factory randomness can't land on deadline-passed now
that it renders distinctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): review-round fixes — deadline-passed date row, archived cert-note gating, a11y
- deadline-passed keeps its normal date row; "content available anytime" archived-only
- archived "Certificate deadline passed" note gated on the run having offered a cert
- drop the in-card note for deadline-passed (redundant with its alert)
- a11y: accessible name on the loading enroll button; accessible framing for the
discounted certificate price (mirrors the program path)
- test: pin multi-run payment-deadline placement + no-runs section-divider absence
- reuse the dateLoading skeleton constant
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): session selector — always show dates, italic 'Start Anytime' annotation, truncate collapsed value
- dropdown options always render the date range; self-paced past-start runs append an
italic '— Start Anytime' annotation instead of a bare 'Anytime' (multiple such runs
were previously indistinguishable)
- collapsed value drops the redundant '— Enrolled' marker and ellipsis-truncates,
fixing overflow in the narrow sidebar column
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): session selector — collapsed dates-only, anytime line, sort by start date
Drop the "— Start Anytime" annotation from the dropdown's collapsed value (it
overflowed the narrow sidebar like "— Enrolled" did); the collapsed value is now
dates only. Surface the anytime nature as its own line under the dropdown (above
the payment-deadline line) via SessionAnnotation in CourseSummary, gated on the
selected run starting anytime. Drop the italics on "Start Anytime" everywhere.
Order dropdown options descending by start date (future sessions near the top;
null start last) via a byStartDateDesc comparator, mirroring the "More Dates"
list; the default selection still follows next_run_id.
Spec §4g updated with the ordering, the collapsed dates-only rule, the dedicated
anytime line, and an explicit note that these selector affordances deviate from
Figma (not depicted there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* style(course-infobox): group session sub-lines into one compact block
Start Anytime + payment deadline were two separate grid rows under the session
dropdown, each separated by the row's full 8px gap — reading as airy whitespace.
Group them into a single SessionSubText block with a tight 2px inter-line gap;
the dropdown→note spacing (the row's 8px rowGap) is unchanged. They now read as
one unit of secondary text while payment deadline keeps its own line and weight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): show in-card "Certificate deadline passed" for deadline-passed too
Previously the in-card note was archived-only — dropped for deadline-passed as
redundant with that scenario's own "Certificate deadline has passed." alert. But
that made the two passed-deadline states inconsistent: deadline-passed conveyed
it only via the alert, archived via the in-card line. Show the in-card line in
both (gated on the run having offered a certificate), matching Figma frame
39861:102467, which shows both the alert and the in-card line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(course-infobox): annotate non-upgradable session options "(no certificate available)"
Mirror the legacy enrollment dialog (CourseProductDetailEnroll.js): a run whose
certificate can no longer be purchased (is_upgradable === false) appends a
"(no certificate available)" note to its dropdown option, so a user choosing a
session knows before enrolling. Open-menu options only; the collapsed value
stays dates-only. Tests asserting exact date labels now pin is_upgradable since
the factory randomizes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): address review-round-2 findings (box count, a11y, dedup)
Five Minor findings from the multi-lens review:
- InfoBoxCourse: count an enrolled run as one offering box even when its
scenario is "none" (enrolled collapse supersedes degraded scenarios, §4h).
Previously `scenario === "none"` was checked before `isEnrolled`, so an
enrolled user on a paid-only-expired run rendered the Enrolled link but
counted 0 boxes (data-boxes=1, divider suppressed). New box-count test.
- CourseEnrollArea: pin the busy button's accessible name ("Loading") in the
loading-state test — a regression dropping the aria-label would have stayed
green (WCAG 4.1.2).
- courseRun: hoist runStartsAnytime and byStartDateDesc into the shared module;
SessionSelect and ProductSummary now import them instead of keeping
byte-identical copies, and CourseDatesRow uses the shared sort comparator.
- CourseEnrollArea: wrap the busy LoadingSpinner in <span aria-hidden> so the
spinner is actually hidden from assistive tech — ol-components LoadingSpinner
drops aria-hidden, leaking a redundant role=progressbar named "Loading".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(course-infobox): model course scenario as {offering, status} union
getCourseScenario now returns a discriminated union with two orthogonal facets —
what's enrollable (offering: none|free|paid|both) and the lifecycle (status:
active|deadlinePassed|archived) — instead of a flat 6-value enum. Typed so the
impossible combinations are unrepresentable (a degraded run can't offer a
purchasable certificate; archived is always audit-only). Consumers simplify where
the old enum forced "archived || deadlinePassed" repetition:
- suppressPaymentDeadline / deadlineNote key off `status !== "active"`
- offeringBoxes keys off `offering`
- useCourseEnrollment builds options from `offering` (paid + free tracks) instead
of a 6-case switch; the free label is derived from status
Behavior change: a paid-only run past its deadline is now {deadlinePassed, none}
and surfaces the "Certificate deadline has passed." warning (no enroll button)
instead of silently rendering nothing.
Also folds in two earlier-agreed changes that touch the same files:
- collapse EnrollActionKind "access" into "free" (identical free-enrollment path;
only the button label differed)
- equal-height offering cards in the tablet "both" layout, driven from BoxGrid CSS
(fills each card's flex chain to its stretched cell, CTA pinned to the bottom)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(course-infobox): tighten scenario model + drop dangling spec refs
Review-round-3 fixes (all behavior-preserving except the new test):
- getCourseScenario now owns `offeredCertificate` on its degraded variants;
CourseEnrollArea reads the flag instead of re-running getEnrollmentType, so
enrollment-mode parsing stays in courseRun.ts.
- Add an offeringBoxCount(scenario, isEnrolled) helper as the single owner of
the box-count mapping; InfoBoxCourse consumes it instead of a hand-mirrored
ternary.
- Drop the unused `disabled` field from EnrollAction — busy state already lives
on EnrollButton via the pending/loading props.
- Add a hook test pinning the approved paid-only-past-deadline behavior
(offering "none" → no enroll button) at the offering→button mapping layer.
- Remove comments that pointed at the gitignored spec (§4g/§4h/§5, "the spec
pinned…"), inlining the rationale instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(course-infobox): extract shared TrackCard, decouple grid from card DOM
The Certificate Track and Learn for Free cards duplicated ~80 lines of
identical styled-component scaffold, and BoxGrid reached three levels into that
private DOM (`[data-card] > * > * > :last-child`) to equalize card height in the
side-by-side layout.
- Extract a shared TrackCard primitive (variant-driven surface, header, price,
optional note slot, feature bullets, action) that both cards compose.
- Move the fill-to-stretch + bottom-align-CTA behavior into TrackCard via a
`fill` prop the cards opt into for the "both" layout; remove the
DOM-structure-coupled CSS from BoxGrid. Behavior-preserving — `fill` is a
no-op wherever the grid cell is only content-height.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(course-infobox): trim comments that won't age well
Review-round-4 comment hygiene — strip process/temporal grounding and
redundancy while keeping the durable rationale:
- Drop the dead `CourseProductDetailEnroll` legacy-symbol citation in
getCourseScenario (the symbol isn't in this repo).
- Replace "matching Figma (…leaves placement open)" with the actual spatial
reason the degraded notices sit where they do.
- Drop cross-references that couple a comment to a sibling's current impl
(useCertificatePrice → ProductSummary program-price path).
- De-duplicate the offeringBoxCount contract (kept on the helper's doc, trimmed
at the call site) and the byStartDateDesc note (kept on the comparator's doc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(course-infobox): show full cert price + finaid text, drop the strikethrough
On the course Certificate Track card, financial aid no longer discounts the
displayed price (the struck-through original / discounted final is removed).
Strikethrough already means "purchased separately" on program cards, so reusing
it for finaid overloaded the same visual with two meanings.
Now the card always shows the full certificate price and conveys finaid as text:
- approved → "Financial assistance approved (applied at checkout)" (the discount
applies later, in checkout; an approved-but-$0 discount is an accepted edge)
- not yet approved → "Financial assistance available"
Both states keep linking to the financial-aid form. Course-only; the program
price path is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): tablet layout — linear metadata in 2-box, un-stretch enrolled button
Two tablet-only (sm–md) fixes:
- SummaryRows: the 2-column metadata split only reads well full-width. In the
2-box course layout the metadata sits in a half-width grid cell, so the split
crammed the rows. Gate it behind a $tabletColumns prop; CourseInfoBox passes 1
when boxCount === 2.
- Enrolled state: EnrolledLink was a bare grid child and stretched to the tall
metadata column's height. Wrap it in OfferingCell so it keeps its natural
button height pinned to the top, like the other offering boxes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(course-infobox): collapse repeated scenario tests into test.each
Four review-identified consolidations, each a parameterized table with
meaningful per-row labels. No coverage change (still 318 tests) — every row
pins the same distinct branch the standalone test did; this only removes
copy-pasted setup/assertion boilerplate:
- useCourseEnrollment: 7 scenario→label/kind tests → one table (the biggest
boilerplate source; degraded rows still assert their CourseScenario shape).
- InfoBoxCourse: 3 not-enrolled data-boxes tests → one table (kept the no-runs
and the two enrolled cases separate — distinct setup/assertions).
- CoursePage: 4 header-scenario tests → one table (kept enrolled + the
header↔InfoBox run-sync test separate).
- CertificateTrackCard + CourseEnrollArea: the finaid available/approved pairs
→ tables (kept CourseEnrollArea's "full price, not a discount" test separate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(course-infobox): drop unnecessary flexible-price overrides
The finaid tests spelled out the full product_flexible_price discount object,
but "approved" only keys off product_flexible_price?.id (useCertificatePrice),
and the flexiblePrice factory already defaults that nested discount to a real
id. So:
- approved link case → makeFlexiblePrice() (factory default is "approved")
- available case → makeFlexiblePrice({ product_flexible_price: null })
- full-price test → makeDiscount({ discount_type, amount }); only the $25
dollars-off is under test (it's the would-be $75 that must NOT render), the
factory fills the rest. Dropped the unused id/price overrides.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(course-infobox): drop more unnecessary factory overrides
A second, broader sweep (not anchored on the product_flexible_price pattern)
found three more:
- ProductSummary: hand-rolled 11-field product_flexible_price discount literal →
makeDiscount({ amount, discount_type }); only those two fields drive the
asserted $125/$200/"applied", the factory fills the rest.
- ProductSummary: product({ price: "1499.00" }) → product(); the test asserts
formatPrice(product.price), so the specific value was never needed.
- CoursePage: product({ price: "500" }) → product(); the price is never asserted
(the test only checks the program-bundle upsell renders).
Verified no flakiness: scripts/test/jest-repeat.sh 30 over both files (varied
seeds) — all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): fix SessionSelect date-range hydration mismatch
formatDate renders in the local timezone, so a run timestamp near a day
boundary formatted to a different calendar date on the (UTC) server than in
the browser — a hydration error on the collapsed dropdown value (and option
labels). Wrap the date-range output in <NoSSR> (the same pattern LocalDate and
the payment-deadline line already use), so the server and first client paint
render nothing and the local date fills in after mount. Local-tz formatting is
kept, so the dropdown stays consistent with the date row's LocalDate output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* style(course-infobox): use darkGreen token for program free-box gradient
Swap the hardcoded `#004D1A` in ProgramStartForFreeBox's gradient for
`theme.custom.colors.darkGreen` (exact match). Wires the previously-unused
theme arg through. No visual change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(course-infobox): scope enroll error alert to enrollment actions
isError previously OR'd in enrollmentsIsError (failure to load the user's
enrolled-run list). That made the enroll area show "There was a problem
processing your enrollment" when the user hadn't attempted to enroll — only
the read of their enrollment status failed. Limit isError to actual basket /
create-enrollment action failures; a status-load failure now degrades silently
(user treated as not-enrolled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(course-infobox): mock main's gtm view trackers after rebase
Rebasing onto main pulled in the trackViewCoursePage/trackCourseProgramView
useEffect (PR #3524). The CoursePage test's partial gtm mock only stubbed
trackCourseEnrolled, so those two became undefined and the effect threw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

What are the relevant tickets?
NA
Description (What does it do?)
Adds GTM triggers to handle some more actions (course enrollment, adding to cart, more) as well as support for reading and triggering based on UTM codes included in the URL.
How can this be tested?
Navigate to the site for the first time in a session (or clear sessionStorage first):
sessionStorage.removeItem("gtm_landing_page_tracked")
Reload the page. One landing-page-arrival event should appear in dataLayer.
Clear sessionStorage, then navigate with Google ad UTM params:
/?utm_source=google&utm_medium=cpc&utm_campaign=test
Expect a google-ad-arrival event (in addition to landing-page-arrival).
Also works with just a gclid param: /?gclid=abc123
/?utm_source=linkedin&utm_medium=paid&utm_campaign=test
Or with /?li_fat_id=abc123. Expect a linkedin-ad-arrival event.
On a fresh browser, visit any page (this sets gtm_has_visited in localStorage). Close the tab, open a new tab, and visit again. Expect a return-visit event on the second visit.
/?utm_source=facebook&utm_medium=social
Expect an organic-social-click event with social-platform: "facebook".
Navigate to any MITx Online course page (e.g. /courses/<readable_id>). Both view-course-page and course-program-view events should fire on load.
On a program page, expand the About section. Expect a view-program-details event wit
On a course or program page, open the Stay Updated modal and submit the email form. Expect a sign-up-for-updates event on successful submission.
On a paid MITx Online course page, click the Enroll button. Expect start-enrollment.nd proceeds to checkout, also expect begin-checkout.
In the enrollment dialog, when a paid product is selected, expect an addToCart event, and course-price.
Type a query into the main search field and submit. Expect a site-search event with search-query set.
On the search/catalog page, check a filter (department, topic, etc.). Expect a catalog-filter event with filter-name and filter-value.
Navigate to a video page and play it. Expect video-start immediately. Expect video-50-percent once the video reaches the halfway point.