Skip to content

Add course enrollment gtm#3524

Merged
cp-at-mit merged 27 commits into
mainfrom
add-course-enrollment-gtm
Jun 30, 2026
Merged

Add course enrollment gtm#3524
cp-at-mit merged 27 commits into
mainfrom
add-course-enrollment-gtm

Conversation

@cp-at-mit

Copy link
Copy Markdown
Contributor

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?

  1. Landing Page Arrival (landing-page-arrival)

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.


  1. Google Ad Arrival (google-ad-arrival)

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


  1. LinkedIn Ad Arrival (linkedin-ad-arrival)

/?utm_source=linkedin&utm_medium=paid&utm_campaign=test

Or with /?li_fat_id=abc123. Expect a linkedin-ad-arrival event.


  1. Return Visit (return-visit)

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.


  1. Organic Social Click (organic-social-click)

/?utm_source=facebook&utm_medium=social

Expect an organic-social-click event with social-platform: "facebook".


  1. View Course Page (view-course-page) + Course/Program View (course-program-view)

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.


  1. View Program Details (view-program-details)

On a program page, expand the About section. Expect a view-program-details event wit


  1. Sign Up for Updates (sign-up-for-updates)

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.


  1. Start Enrollment (start-enrollment) + Begin Checkout (begin-checkout)

On a paid MITx Online course page, click the Enroll button. Expect start-enrollment.nd proceeds to checkout, also expect begin-checkout.


  1. Add to Cart (addToCart)

In the enrollment dialog, when a paid product is selected, expect an addToCart event, and course-price.


  1. Site Search (site-search)

Type a query into the main search field and submit. Expect a site-search event with search-query set.


  1. Catalog Filter (catalog-filter)

On the search/catalog page, check a filter (department, topic, etc.). Expect a catalog-filter event with filter-name and filter-value.


  1. Video Start (video-start) + 50% (video-50-percent)

Navigate to a video page and play it. Expect video-start immediately. Expect video-50-percent once the video reaches the halfway point.

cp-at-mit and others added 13 commits June 9, 2026 11:40
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.
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>
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@cp-at-mit cp-at-mit marked this pull request as ready for review June 25, 2026 19:58
Copilot AI review requested due to automatic review settings June 25, 2026 19:58
@dsubak dsubak self-requested a review June 25, 2026 20:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 utm parsing + helpers to classify Google/LinkedIn ad traffic and organic social traffic.
  • Expand gtm analytics 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 utm helpers and gtm tracking 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.

Comment thread frontends/main/src/page-components/SearchField/SearchField.tsx Outdated
Comment thread frontends/main/src/app/(site)/SiteProviders.tsx
Comment thread frontends/main/src/common/analytics/gtm.test.ts
cp-at-mit and others added 4 commits June 25, 2026 16:21
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>
@dsubak

dsubak commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All our other events use dash-separated-lowercase as a naming convention. Should we change this to match?

@dsubak dsubak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine from what I've tested - I left a couple comments, and I'd recommend we get a react subject matter expert to take a look as well, but in practice I was seeing all the events come through via the google tag assistant configured against my local.

Below is one such example

Image

@cp-at-mit cp-at-mit merged commit d4958dc into main Jun 30, 2026
13 checks passed
@cp-at-mit cp-at-mit deleted the add-course-enrollment-gtm branch June 30, 2026 18:01
@odlbot odlbot mentioned this pull request Jul 1, 2026
3 tasks
ChristopherChudzicki added a commit that referenced this pull request Jul 1, 2026
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>
ChristopherChudzicki added a commit that referenced this pull request Jul 1, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants