diff --git a/RELEASE.rst b/RELEASE.rst index 0a4f951be6..953f42d02a 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.66.6 +-------------- + +- fix: improve the hover state of video and adjust the border of breadcrum (#3301) +- fix: remove the top border when there is only single item and removed the current episode from the more episode list (#3299) +- adds a "Receipt" menu item (#3260) +- feat: add podcast episode page and few fixes in podcast page (#3283) +- use Intl to get native language names (#3297) + Version 0.66.3 (Released May 05, 2026) -------------- diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index a3bdf6cc5f..cea1282c31 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -1443,7 +1443,7 @@ describe("ContractContent", () => { expect(card).toHaveTextContent("Module in English") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) await waitFor(() => { expect(root.getByTestId("enrollment-card-desktop")).toHaveTextContent( @@ -1549,7 +1549,7 @@ describe("ContractContent", () => { expect(card).toHaveTextContent("Collection English") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) await waitFor(() => { expect( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx index cae4957486..acaff061d2 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -2138,5 +2138,202 @@ describe.each([ screen.getByRole("menuitem", { name: "Unenroll" }), ).toBeInTheDocument() }) + + test("Receipt menu item appears for verified course run enrollment", async () => { + setupUserApis() + const course = mitxOnlineCourse() + const run = course.courseruns[0] + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { ...run, course }, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + expect( + screen.getByRole("menuitem", { name: "Receipt" }), + ).toBeInTheDocument() + }) + + test("Receipt menu item does not appear for audit course run enrollment", async () => { + setupUserApis() + const course = mitxOnlineCourse() + const run = course.courseruns[0] + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [], + enrollment_mode: EnrollmentMode.Audit, + run: { ...run, course }, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + expect( + screen.queryByRole("menuitem", { name: "Receipt" }), + ).not.toBeInTheDocument() + }) + + test("Receipt menu item links to correct MITx Online URL for verified course run enrollment", async () => { + setupUserApis() + const course = mitxOnlineCourse() + const run = mitxonline.factories.courses.courseRun({ id: 42 }) + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { ...run, course }, + }) + + const windowOpenSpy = jest + .spyOn(window, "open") + .mockImplementation(() => null) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + const receiptItem = screen.getByRole("menuitem", { name: "Receipt" }) + await user.click(receiptItem) + + expect(windowOpenSpy).toHaveBeenCalledWith( + mitxonlineLegacyUrl("/orders/receipt/by-run/42/"), + "_blank", + "noopener,noreferrer", + ) + windowOpenSpy.mockRestore() + }) + + test("Receipt menu item appears for verified program enrollment", async () => { + setupUserApis() + const program = mitxonline.factories.programs.simpleProgram() + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + enrollment_mode: EnrollmentMode.Verified, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + expect( + screen.getByRole("menuitem", { name: "Receipt" }), + ).toBeInTheDocument() + }) + + test("Receipt menu item does not appear for audit program enrollment", async () => { + setupUserApis() + const program = mitxonline.factories.programs.simpleProgram() + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + enrollment_mode: EnrollmentMode.Audit, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + expect( + screen.queryByRole("menuitem", { name: "Receipt" }), + ).not.toBeInTheDocument() + }) + + test("Receipt menu item links to correct MITx Online URL for verified program enrollment", async () => { + setupUserApis() + const program = mitxonline.factories.programs.simpleProgram({ id: 99 }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + enrollment_mode: EnrollmentMode.Verified, + }) + + const windowOpenSpy = jest + .spyOn(window, "open") + .mockImplementation(() => null) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + const receiptItem = screen.getByRole("menuitem", { name: "Receipt" }) + await user.click(receiptItem) + + expect(windowOpenSpy).toHaveBeenCalledWith( + mitxonlineLegacyUrl("/orders/receipt/by-program/99/"), + "_blank", + "noopener,noreferrer", + ) + windowOpenSpy.mockRestore() + }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index ad0b740e94..edf1d662c3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -21,6 +21,7 @@ import { useFeatureFlagEnabled } from "posthog-js/react" import { FeatureFlags } from "@/common/feature_flags" import { EnrollmentStatusIndicator } from "./EnrollmentStatusIndicator" +import { getReceiptMenuItem } from "./receiptMenuItem" import { EmailSettingsDialog, JustInTimeDialog, @@ -37,10 +38,10 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { coursePageView, programPageView, programView } from "@/common/urls" import { - mitxonlineLegacyUrl, getCourseEnrollmentAction, getEnrollmentType, isVerifiedEnrollmentMode, + mitxonlineLegacyUrl, } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" @@ -220,6 +221,12 @@ const getContextMenuItems = ( }, }) } + + const receiptMenuItem = getReceiptMenuItem( + resource.data.enrollment_mode, + `/orders/receipt/by-program/${program.id}/`, + ) + if (receiptMenuItem) menuItems.push(receiptMenuItem) } if (resource.type === DashboardType.CourseRunEnrollment) { const detailsUrl = useProductPages @@ -259,6 +266,12 @@ const getContextMenuItems = ( }, ) + const receiptMenuItem = getReceiptMenuItem( + resource.data.enrollment_mode, + `/orders/receipt/by-run/${resource.data.run.id}/`, + ) + if (receiptMenuItem) courseMenuItems.push(receiptMenuItem) + menuItems.push(...courseMenuItems) } return [...menuItems, ...additionalItems] diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index b68a09ee14..ff076ea89d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -2543,7 +2543,7 @@ describe("EnrollmentDisplay", () => { expect(card).toHaveTextContent("Start Course") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) const desktopCard = await screen.findByTestId("enrollment-card-desktop") await within(desktopCard).findByText("Modulo en Espanol") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.test.tsx new file mode 100644 index 0000000000..45e6cb06a9 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.test.tsx @@ -0,0 +1,65 @@ +import * as mitxonline from "api/mitxonline-test-utils" +import { EnrollmentModeEnum } from "@mitodl/mitxonline-api-axios/v2" +import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import { DashboardType, getContextMenuItems } from "./ModuleCard" + +describe("ModuleCard context menu receipt item", () => { + test("shows Receipt item for verified enrollment", () => { + const course = mitxonline.factories.courses.course() + const run = mitxonline.factories.courses.courseRun({ id: 42 }) + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + enrollment_mode: EnrollmentModeEnum.Verified, + run: { ...run, course }, + }) + + const items = getContextMenuItems("Test Course", { + type: DashboardType.CourseRunEnrollment, + data: enrollment, + }) + + expect(items.some((item) => item.label === "Receipt")).toBe(true) + }) + + test("does not show Receipt item for audit enrollment", () => { + const course = mitxonline.factories.courses.course() + const run = mitxonline.factories.courses.courseRun({ id: 42 }) + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + enrollment_mode: EnrollmentModeEnum.Audit, + run: { ...run, course }, + }) + + const items = getContextMenuItems("Test Course", { + type: DashboardType.CourseRunEnrollment, + data: enrollment, + }) + + expect(items.some((item) => item.label === "Receipt")).toBe(false) + }) + + test("Receipt item opens the expected MITx Online URL", () => { + const course = mitxonline.factories.courses.course() + const run = mitxonline.factories.courses.courseRun({ id: 42 }) + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + enrollment_mode: EnrollmentModeEnum.Verified, + run: { ...run, course }, + }) + const windowOpenSpy = jest + .spyOn(window, "open") + .mockImplementation(() => null) + + const items = getContextMenuItems("Test Course", { + type: DashboardType.CourseRunEnrollment, + data: enrollment, + }) + const receiptItem = items.find((item) => item.label === "Receipt") + + receiptItem?.onClick?.() + + expect(windowOpenSpy).toHaveBeenCalledWith( + mitxonlineLegacyUrl("/orders/receipt/by-run/42/"), + "_blank", + "noopener,noreferrer", + ) + windowOpenSpy.mockRestore() + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index 5efe28f897..9529048dc7 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -31,6 +31,7 @@ import { } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" +import { getReceiptMenuItem } from "./receiptMenuItem" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, @@ -230,6 +231,12 @@ const getContextMenuItems = ( }, ) + const receiptMenuItem = getReceiptMenuItem( + resource.data.enrollment_mode, + `/orders/receipt/by-run/${resource.data.run.id}/`, + ) + if (receiptMenuItem) courseMenuItems.push(receiptMenuItem) + menuItems.push(...courseMenuItems) } return [...menuItems, ...additionalItems] diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index d0b9f247df..79f17cb91c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -18,6 +18,16 @@ type LanguageOptionWithEnrollability = CourseRunLanguageOption & { } describe("languageOptions", () => { + const setIntlDisplayNames = ( + value: typeof Intl.DisplayNames | undefined, + ): void => { + Object.defineProperty(Intl, "DisplayNames", { + value, + configurable: true, + writable: true, + }) + } + test("normalizes language keys", () => { expect( getLanguageOptionKey({ @@ -159,7 +169,7 @@ describe("languageOptions", () => { }) expect(options[1]).toEqual({ value: "language:es", - label: "Español", + label: "español", }) }) @@ -213,6 +223,179 @@ describe("languageOptions", () => { expect(selectedRun?.id).toBe(spanishRun.id) }) + test("uses static fallback labels when Intl.DisplayNames is unavailable", () => { + const originalDisplayNames = Intl.DisplayNames + setIntlDisplayNames(undefined) + + try { + const run = factories.courses.courseRun({ + id: 7001, + title: "Spanish LATAM", + courseware_id: "cw-es-419", + courseware_url: "https://example.com/cw-es-419", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "es-419", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options).toEqual([ + { + value: "language:es-419", + label: "español (Latinoamérica)", + }, + ]) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + + test("falls back to the base language subtag when regional code is unresolved", () => { + const originalDisplayNames = Intl.DisplayNames + + class MockDisplayNames { + of(code: string): string | undefined { + if (code === "zz-419") { + return undefined + } + if (code === "zz") { + return "Zed" + } + return undefined + } + } + + setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) + + try { + const run = factories.courses.courseRun({ + id: 7002, + title: "Mock Regional", + courseware_id: "cw-zz-419", + courseware_url: "https://example.com/cw-zz-419", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "zz-419", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options).toEqual([ + { + value: "language:zz-419", + label: "Zed", + }, + ]) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + + test("memoizes native language labels by language code", () => { + const originalDisplayNames = Intl.DisplayNames + let constructorCalls = 0 + + class MockDisplayNames { + constructor() { + constructorCalls += 1 + } + + of(code: string): string | undefined { + if (code === "es") { + return "español" + } + return undefined + } + } + + setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) + + try { + const runA = factories.courses.courseRun({ + id: 7101, + title: "Spanish A", + courseware_id: "cw-es-7101", + courseware_url: "https://example.com/cw-es-7101", + is_enrollable: true, + }) + + const runB = factories.courses.courseRun({ + id: 7102, + title: "Spanish B", + courseware_id: "cw-es-7102", + courseware_url: "https://example.com/cw-es-7102", + is_enrollable: true, + }) + + const courseA = factories.courses.course({ + courseruns: [runA], + next_run_id: runA.id, + language_options: [ + { + id: runA.id, + courseware_id: runA.courseware_id, + courseware_url: runA.courseware_url ?? "", + language: "es", + title: runA.title, + run_tag: runA.run_tag, + }, + ], + }) + + const courseB = factories.courses.course({ + courseruns: [runB], + next_run_id: runB.id, + language_options: [ + { + id: runB.id, + courseware_id: runB.courseware_id, + courseware_url: runB.courseware_url ?? "", + language: "es", + title: runB.title, + run_tag: runB.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([courseA, courseB]) + + expect(options).toEqual([ + { + value: "language:es", + label: "español", + }, + ]) + expect(constructorCalls).toBe(1) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + test("keeps language when one of multiple matching runs is enrollable", () => { const englishRun = factories.courses.courseRun({ id: 6101, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index efebedbda2..05389f4d05 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -7,22 +7,6 @@ import type { } from "@mitodl/mitxonline-api-axios/v2" import { getBestRun, selectBestEnrollment } from "./helpers" -const LANGUAGE_CODE_TO_NATIVE_NAME: Record = { - ar: "العربية", - de: "Deutsch", - "de-de": "Deutsch", - el: "Ελληνικά", - es: "Español", - "es-419": "Español (Latinoamérica)", - fr: "Français", - pt: "Português", - ja: "日本語", - "pt-br": "Português (Brasil)", - zh: "中文", - "zh-hans": "简体中文", - en: "English", -} - const getLanguageCode = (option: CourseRunLanguageOption): string | null => { const normalized = option.language?.trim().toLowerCase().replace(/_/g, "-") return normalized || null @@ -41,19 +25,106 @@ const getLanguageCodeFromOptionKey = (optionKey: string): string | null => { return code || null } +const FALLBACK_NATIVE_LANGUAGE_NAMES: Record = { + ar: "العربية", + de: "Deutsch", + en: "English", + es: "español", + "es-419": "español (Latinoamérica)", + fr: "français", + hi: "हिन्दी", + it: "italiano", + ja: "日本語", + ko: "한국어", + pt: "português", + "pt-br": "português (Brasil)", + ru: "русский", + zh: "中文", + "zh-cn": "简体中文", + "zh-tw": "繁體中文", +} + +const nativeLanguageNameCache = new Map() +let cachedDisplayNamesRef: typeof Intl.DisplayNames | undefined = + Intl.DisplayNames + +const ensureNativeLanguageNameCacheIsFresh = (): void => { + if (Intl.DisplayNames !== cachedDisplayNamesRef) { + cachedDisplayNamesRef = Intl.DisplayNames + nativeLanguageNameCache.clear() + } +} + +const getFallbackNativeLanguageName = (languageCode: string): string | null => { + const exactMatch = FALLBACK_NATIVE_LANGUAGE_NAMES[languageCode] + if (exactMatch) { + return exactMatch + } + + const baseLanguageSubtag = languageCode.split("-")[0] + if (!baseLanguageSubtag) { + return null + } + + return ( + FALLBACK_NATIVE_LANGUAGE_NAMES[baseLanguageSubtag] ?? baseLanguageSubtag + ) +} + +const getNativeLanguageName = (languageCode: string): string => { + ensureNativeLanguageNameCacheIsFresh() + + const normalizedLanguageCode = languageCode.trim().toLowerCase() + const baseLanguageSubtag = normalizedLanguageCode.split("-")[0] + + const cachedLabel = nativeLanguageNameCache.get(normalizedLanguageCode) + if (cachedLabel) { + return cachedLabel + } + + let resolvedLabel: string | null = null + + try { + if (typeof Intl.DisplayNames === "function") { + const displayNames = new Intl.DisplayNames([normalizedLanguageCode], { + type: "language", + }) + const label = displayNames.of(normalizedLanguageCode) + if (label && label.toLowerCase() !== normalizedLanguageCode) { + resolvedLabel = label + } + + if ( + !resolvedLabel && + baseLanguageSubtag && + baseLanguageSubtag !== normalizedLanguageCode + ) { + const baseLabel = displayNames.of(baseLanguageSubtag) + if (baseLabel && baseLabel.toLowerCase() !== baseLanguageSubtag) { + resolvedLabel = baseLabel + } + } + } + } catch { + // Fall through to static fallback labels. + } + + const finalLabel = + resolvedLabel ?? + getFallbackNativeLanguageName(normalizedLanguageCode) ?? + normalizedLanguageCode + + nativeLanguageNameCache.set(normalizedLanguageCode, finalLabel) + return finalLabel +} + const getLanguageOptionLabel = (option: CourseRunLanguageOption): string => { const languageCode = getLanguageCode(option) if (!languageCode) { return "" } - const exact = LANGUAGE_CODE_TO_NATIVE_NAME[languageCode] - if (exact) { - return exact - } - - const baseCode = languageCode.split("-")[0] - return LANGUAGE_CODE_TO_NATIVE_NAME[baseCode] ?? languageCode + return getNativeLanguageName(languageCode) } type ExtendedLanguageOption = CourseRunLanguageOption & { diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts new file mode 100644 index 0000000000..ac90a57595 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.test.ts @@ -0,0 +1,11 @@ +import { getReceiptMenuItem } from "./receiptMenuItem" + +describe("getReceiptMenuItem", () => { + test("returns null when enrollment mode is undefined", () => { + const menuItem = getReceiptMenuItem( + undefined, + "/orders/receipt/by-program/99/", + ) + expect(menuItem).toBeNull() + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts new file mode 100644 index 0000000000..3b3e77244d --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/receiptMenuItem.ts @@ -0,0 +1,27 @@ +import { SimpleMenuItem } from "ol-components" +import { + isVerifiedEnrollmentMode, + mitxonlineLegacyUrl, +} from "@/common/mitxonline" + +const getReceiptMenuItem = ( + enrollmentMode: string | null | undefined, + receiptPath: string, +): SimpleMenuItem | null => { + if (!enrollmentMode || !isVerifiedEnrollmentMode(enrollmentMode)) return null + + return { + className: "dashboard-card-menu-item", + key: "receipt", + label: "Receipt", + onClick: () => { + window.open( + mitxonlineLegacyUrl(receiptPath), + "_blank", + "noopener,noreferrer", + ) + }, + } +} + +export { getReceiptMenuItem } diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx index 0b1bcc9810..2e4eae2b0e 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -1,12 +1,12 @@ "use client" -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useRef } from "react" import { Breadcrumbs, Typography, styled, useMediaQuery } from "ol-components" import type { Theme } from "ol-components" import { Button, ActionButton } from "@mitodl/smoot-design" -import { RiPlayFill } from "@remixicon/react" +import { RiPlayFill, RiPauseFill } from "@remixicon/react" import PodcastPlayer, { PLAYER_HEIGHT } from "./PodcastPlayer" -import type { PodcastTrack } from "./PodcastPlayer" +import type { PodcastTrack, PodcastPlayerHandle } from "./PodcastPlayer" import { useLearningResourcesDetail, useInfiniteLearningResourceItems, @@ -156,13 +156,23 @@ const EpisodeList = styled.ul({ gridTemplateColumns: "1fr", }) -const EpisodeRow = styled.li(({ theme }) => ({ +const EpisodeRow = styled("li", { + shouldForwardProp: (prop) => prop !== "isEpisodePage", +})<{ isEpisodePage?: boolean }>(({ theme, isEpisodePage }) => ({ margin: 0, display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between", - padding: "28px 16px", + padding: !isEpisodePage ? "28px 16px" : "28px 0px", + ...(isEpisodePage && { + "&:first-of-type": { paddingTop: 0, boxShadow: "none" }, + // When there is only one episode (first AND last), keep only the bottom + // shadow — the top shadow from :first-of-type should remain removed. + "&:first-of-type:last-child": { + boxShadow: `0 1px 0 ${theme.custom.colors.lightGray2}`, + }, + }), boxShadow: `0 -1px 0 ${theme.custom.colors.lightGray2}`, gap: "16px", "&:last-child": { @@ -199,7 +209,6 @@ const EpisodeTitleLink = styled.span(({ theme }) => ({ color: theme.custom.colors.darkGray2, textDecoration: "none", display: "block", - marginBottom: "8px", fontSize: "18px", fontStyle: "normal", fontWeight: theme.typography.fontWeightBold, @@ -207,6 +216,10 @@ const EpisodeTitleLink = styled.span(({ theme }) => ({ })) const StyledButton = styled(Button)(({ theme }) => ({ + padding: "16px 20px", + ...theme.typography.body1, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "16px", [theme.breakpoints.down("sm")]: { width: "100%", }, @@ -225,11 +238,16 @@ const StyledShowMore = styled(Button)(({ theme }) => ({ }, })) +const StyledIcon = styled(RiPlayFill)({ + width: "24px !important", + height: "24px !important", +}) + const BreadcrumbBar = styled.div(({ theme }) => ({ - padding: "32px 0 16px 0", - borderBottom: `2px solid ${theme.custom.colors.red}`, + padding: "18px 0 2px 0", + borderBottom: `1px solid ${theme.custom.colors.red}`, [theme.breakpoints.down("sm")]: { - padding: "16px 0 0px 0", + padding: "12px 0 0px 0", }, })) @@ -254,7 +272,7 @@ const StyledDot = styled.span(({ theme }) => ({ })) const PageSection = styled.div(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, + backgroundColor: theme.custom.colors.white, })) const EpisodeMeta = styled(Typography)(({ theme }) => ({ @@ -269,7 +287,10 @@ const PlayButton = styled(ActionButton, { isPlaying: boolean }>(({ theme, isPlaying }) => [ { + width: "48px", + height: "48px", color: theme.custom.colors.darkGray2, + backgroundColor: theme.custom.colors.white, borderColor: "currentColor", "&:hover:not(:disabled)": { color: theme.custom.colors.red, @@ -287,18 +308,22 @@ const PlayButton = styled(ActionButton, { /* ── Episode row component ── */ -type EpisodeItemProps = { +export type EpisodeItemProps = { episode: LearningResource onPlayClick: (episode: LearningResource) => void + onPauseClick?: () => void isPlaying: boolean isPlayable: boolean + isEpisodePage?: boolean } -const EpisodeItem: React.FC = ({ +export const EpisodeItem: React.FC = ({ episode, onPlayClick, + onPauseClick, isPlaying, isPlayable, + isEpisodePage = false, }) => { const podcastEpisode = episode.resource_type === "podcast_episode" ? episode.podcast_episode : null @@ -314,7 +339,10 @@ const EpisodeItem: React.FC = ({ const metaParts = [duration ? `${duration} min` : null, date].filter(Boolean) return ( - onPlayClick(episode)}> + (isPlaying ? onPauseClick?.() : onPlayClick(episode))} + isEpisodePage={isEpisodePage} + > {episode.title} @@ -333,13 +361,15 @@ const EpisodeItem: React.FC = ({ )} - + {isPlaying ? : } @@ -366,6 +396,8 @@ export const PodcastDetailPage: React.FC = ({ const [playingEpisode, setPlayingEpisode] = useState( null, ) + const [isAudioPlaying, setIsAudioPlaying] = useState(false) + const playerRef = useRef(null) const { data: resource } = useLearningResourcesDetail(id) @@ -425,7 +457,11 @@ export const PodcastDetailPage: React.FC = ({ const handlePlayClick = (episode: LearningResource) => { if (!getEpisodeAudioUrl(episode)) return - setPlayingEpisode(episode) + if (playingEpisode?.id === episode.id) { + playerRef.current?.resume() + } else { + setPlayingEpisode(episode) + } } const currentTrack: PodcastTrack | null = playingEpisode @@ -517,7 +553,7 @@ export const PodcastDetailPage: React.FC = ({ handlePlayClick(latestEpisode)} variant="primary" - startIcon={} + startIcon={} disabled={!getEpisodeAudioUrl(latestEpisode)} > Play Latest Episode @@ -540,7 +576,10 @@ export const PodcastDetailPage: React.FC = ({ key={episode.id} episode={episode} onPlayClick={handlePlayClick} - isPlaying={playingEpisode?.id === episode.id} + onPauseClick={() => playerRef.current?.pause()} + isPlaying={ + playingEpisode?.id === episode.id && isAudioPlaying + } isPlayable={Boolean(getEpisodeAudioUrl(episode))} /> ))} @@ -568,8 +607,10 @@ export const PodcastDetailPage: React.FC = ({ {currentTrack && ( setPlayingEpisode(null)} + onPlayStateChange={setIsAudioPlaying} /> )} diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx new file mode 100644 index 0000000000..0922f230f9 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx @@ -0,0 +1,308 @@ +import React from "react" +import { factories, setMockResponse, urls } from "api/test-utils" +import { ResourceTypeEnum } from "api/v1" +import type { LearningResource, PodcastEpisodeResource } from "api/v1" +import { renderWithProviders, screen, user } from "@/test-utils" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { PodcastEpisodeDetailPage } from "./PodcastEpisodeDetailPage" + +jest.mock("posthog-js/react") +jest.mock("@/common/useFeatureFlagsLoaded") + +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) +const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) + +jest.mock("./PodcastPlayer", () => ({ + __esModule: true, + PLAYER_HEIGHT: { desktop: 104, mobile: 220 }, + default: jest.fn( + ({ track }: { track: { title: string; podcastName: string } }) => ( +
+ {track.title} + {track.podcastName} +
+ ), + ), +})) + +const EPISODES_PAGE_SIZE = 5 + +const makeItemsResponse = (episodes: LearningResource[]) => ({ + count: episodes.length, + next: null, + previous: null, + results: episodes.map((resource, i) => ({ + id: i + 1, + child: resource.id, + parent: 0, + position: i + 1, + resource, + })), +}) + +const makePodcastEpisode = ( + overrides: Partial = {}, +): PodcastEpisodeResource => + factories.learningResources.resource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + ...overrides, + }) as PodcastEpisodeResource + +const makePodcast = ( + overrides: Partial = {}, +): LearningResource => + factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Podcast, + ...overrides, + }) + +type SetupOptions = { + episodeOverrides?: Partial + podcastOverrides?: Partial + moreEpisodes?: LearningResource[] +} + +const setupApis = ({ + episodeOverrides = {}, + podcastOverrides = {}, + moreEpisodes, +}: SetupOptions = {}) => { + const podcast = makePodcast(podcastOverrides) + const episode = makePodcastEpisode(episodeOverrides) + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + + const episodeList = moreEpisodes ?? [episode] + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse(episodeList), + ) + + return { episode, podcast } +} + +describe("PodcastEpisodeDetailPage", () => { + beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test("renders episode title and podcast name on the page", async () => { + const { episode, podcast } = setupApis({ moreEpisodes: [] }) + + renderWithProviders( + , + ) + + // Episode title appears in the breadcrumb current item and as the styled heading + const episodeTitles = await screen.findAllByText(episode.title!) + expect(episodeTitles.length).toBeGreaterThanOrEqual(1) + + // Podcast title appears as the EpisodeLabel and in the breadcrumb link + const podcastTitles = screen.getAllByText(podcast.title!) + expect(podcastTitles.length).toBeGreaterThanOrEqual(2) + }) + + test("renders 'More from ' section header", async () => { + const moreEpisodes = [makePodcastEpisode(), makePodcastEpisode()] + const { episode, podcast } = setupApis({ moreEpisodes }) + + renderWithProviders( + , + ) + + await screen.findByText(new RegExp(`More from ${podcast.title}`, "i")) + }) + + test("renders 'More from' episode list items", async () => { + const moreEpisodes = [makePodcastEpisode(), makePodcastEpisode()] + const { episode, podcast } = setupApis({ moreEpisodes }) + + renderWithProviders( + , + ) + + await screen.findByText(moreEpisodes[0].title!) + expect(screen.getByText(moreEpisodes[1].title!)).toBeInTheDocument() + }) + + test("play button is present and enabled when episode has an audio URL", async () => { + const episode = makePodcastEpisode() + // Ensure audio_url is set (factories should set it, but be explicit) + episode.podcast_episode.audio_url = "https://example.com/ep.mp3" + const podcast = makePodcast() + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse([episode]), + ) + + renderWithProviders( + , + ) + + const playButton = await screen.findByRole("button", { + name: /play episode/i, + }) + expect(playButton).not.toBeDisabled() + }) + + test("play button is disabled when episode has no audio source", async () => { + const episode = makePodcastEpisode() + episode.podcast_episode.audio_url = "" + episode.podcast_episode.episode_link = "" + const podcast = makePodcast() + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse([episode]), + ) + + renderWithProviders( + , + ) + + const playButton = await screen.findByRole("button", { + name: /play episode/i, + }) + expect(playButton).toBeDisabled() + }) + + test("clicking play renders the PodcastPlayer with correct track data", async () => { + const episode = makePodcastEpisode() + episode.podcast_episode.audio_url = "https://example.com/ep.mp3" + const podcast = makePodcast() + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse([episode]), + ) + + renderWithProviders( + , + ) + + expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument() + + const playButton = await screen.findByRole("button", { + name: /play episode/i, + }) + await user.click(playButton) + + expect(screen.getByTestId("podcast-player")).toBeInTheDocument() + expect(screen.getByTestId("player-track-title")).toHaveTextContent( + episode.title!, + ) + expect(screen.getByTestId("player-podcast-name")).toHaveTextContent( + podcast.title!, + ) + }) + + test("clicking play in 'More from' list renders the player for that episode", async () => { + const moreEpisode = makePodcastEpisode() + moreEpisode.podcast_episode.audio_url = "https://example.com/more.mp3" + const { episode, podcast } = setupApis({ moreEpisodes: [moreEpisode] }) + + renderWithProviders( + , + ) + + await screen.findByText(moreEpisode.title!) + const playButtons = await screen.findAllByRole("button", { + name: new RegExp(`Play ${moreEpisode.title}`), + }) + await user.click(playButtons[0]) + + expect(screen.getByTestId("podcast-player")).toBeInTheDocument() + expect(screen.getByTestId("player-track-title")).toHaveTextContent( + moreEpisode.title!, + ) + }) + + test("returns null (not found) when feature flag is not loaded yet", () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(false) + mockedUseFeatureFlagEnabled.mockReturnValue(false) + + const episode = makePodcastEpisode() + const podcast = makePodcast() + + setMockResponse.get( + urls.learningResources.details({ id: episode.id }), + episode, + ) + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse([]), + ) + + const { view } = renderWithProviders( + , + ) + + expect(view.container).toBeEmptyDOMElement() + }) +}) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx new file mode 100644 index 0000000000..4253e4df4a --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx @@ -0,0 +1,391 @@ +"use client" + +import React, { useState, useEffect, useRef } from "react" +import { + Breadcrumbs, + Typography, + Container, + styled, + useMediaQuery, +} from "ol-components" +import type { Theme } from "ol-components" +import { Button } from "@mitodl/smoot-design" +import { RiPlayFill, RiPauseFill } from "@remixicon/react" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import PodcastPlayer, { PLAYER_HEIGHT } from "./PodcastPlayer" +import type { PodcastTrack, PodcastPlayerHandle } from "./PodcastPlayer" +import { + useLearningResourcesDetail, + useInfiniteLearningResourceItems, +} from "api/hooks/learningResources" + +import { ResourceTypeEnum } from "api/v1" +import type { LearningResource } from "api/v1" +import moment from "moment" +import { formatDate } from "ol-utilities" +import { HOME, podcastPageView } from "@/common/urls" +import DOMPurify from "isomorphic-dompurify" +import { EpisodeItem } from "./PodcastDetailPage" +import PodcastContainer from "./PodcastContainer" +import { notFound } from "next/navigation" +import Link from "next/link" + +/* ── Layout ── */ + +const EpisodeContainer = styled(Container)(({ theme }) => ({ + maxWidth: "624px !important", + padding: "0 !important", + [theme.breakpoints.down("sm")]: { + padding: "0 16px !important", + }, +})) + +const PageSection = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + minHeight: "100vh", +})) + +const HeaderSection = styled.div(({ theme }) => ({ + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + marginBottom: "64px", + paddingBottom: "64px", + [theme.breakpoints.down("sm")]: { + marginBottom: "24px", + paddingBottom: "24px", + }, +})) + +const EpisodeLabel = styled(Link)(({ theme }) => ({ + color: theme.custom.colors.darkRed, + textTransform: "uppercase" as const, + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightBold, + marginBottom: "32px", + lineHeight: "150%" /* 21px */, + marginTop: "64px", + display: "block", + textDecoration: "none", + "&:hover": { + textDecoration: "underline", + }, + [theme.breakpoints.down("sm")]: { + marginTop: "32px", + marginBottom: "8px", + }, +})) + +const EpisodeTitle = styled(Typography)(({ theme }) => ({ + marginBottom: "32px", + display: "block", + [theme.breakpoints.down("sm")]: { + ...theme.typography.h2, + marginBottom: "18px", + }, +})) + +const MoreItemDescription = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.black, + display: "block", + marginBottom: "24px", + fontSize: "24px", + lineHeight: "30px", + fontWeight: theme.typography.fontWeightBold, + [theme.breakpoints.down("sm")]: { + marginBottom: "24px", + marginTop: "64px", + }, +})) + +const MetaLine = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + marginBottom: "32px", + display: "block", + ...theme.typography.body1, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "150%", + [theme.breakpoints.down("sm")]: { + marginBottom: "16px", + }, +})) + +const Topics = styled.span(({ theme }) => ({ + color: theme.custom.colors.darkGray1, + ...theme.typography.body1, + lineHeight: "20px", + [theme.breakpoints.down("sm")]: { + marginBottom: "16px", + display: "block", + }, +})) + +const Description = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "block", + marginBottom: "32px", + marginTop: "32px", + fontSize: "18px", + fontStyle: "normal", + lineHeight: "32px", + [theme.breakpoints.down("sm")]: { + ...theme.typography.body1, + lineHeight: "24px", + marginTop: "16px", + }, +})) + +const EpisodeList = styled.ul({ + listStyle: "none", + margin: 0, + padding: 0, + display: "grid", + gridTemplateColumns: "1fr", +}) + +export const BreadcrumbBar = styled.div(({ theme }) => ({ + padding: "18px 0 2px 0", + borderBottom: `1px solid ${theme.custom.colors.red}`, + [theme.breakpoints.down("sm")]: { + padding: "12px 0 0 0", + }, +})) + +const ViewAllLink = styled.a(({ theme }) => ({ + color: theme.custom.colors.darkRed, + ...theme.typography.body1, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "150%", + textDecoration: "none", + display: "inline-flex", + alignItems: "center", + gap: "4px", + marginTop: "40px", + marginBottom: "64px", + "&:hover": { + textDecoration: "underline", + }, + [theme.breakpoints.down("sm")]: { + marginBottom: "40px", + }, +})) + +const StyledButton = styled(Button)(({ theme }) => ({ + marginBottom: "32px", + padding: "12px 24px 12px 20px", + minWidth: "175px", + ...theme.typography.body1, + [theme.breakpoints.down("sm")]: { + width: "100%", + marginBottom: "16px", + }, +})) + +/* ── Component ── */ + +type PodcastEpisodeDetailPageProps = { + episodeId: string + podcastId: string | null +} + +export const PodcastEpisodeDetailPage: React.FC< + PodcastEpisodeDetailPageProps +> = ({ episodeId, podcastId }) => { + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")) + const [playingEpisode, setPlayingEpisode] = useState( + null, + ) + const [isAudioPlaying, setIsAudioPlaying] = useState(false) + const playerRef = useRef(null) + + const showPodcastDetailPage = useFeatureFlagEnabled( + FeatureFlags.PodcastDetailPage, + ) + const flagsLoaded = useFeatureFlagsLoaded() + const { data: episode } = useLearningResourcesDetail(Number(episodeId)) + const { data: podcast } = useLearningResourcesDetail(Number(podcastId)) + + const podcastEpisode = + episode?.resource_type === ResourceTypeEnum.PodcastEpisode + ? episode.podcast_episode + : null + + const { data: episodesData } = useInfiniteLearningResourceItems( + Number(podcastId), + { learning_resource_id: Number(podcastId), limit: 5 }, + { enabled: !!podcast }, + ) + const episodes = + episodesData?.pages.flatMap((page) => + page.results + .map((rel) => rel.resource) + .filter( + (r) => + r.resource_type === ResourceTypeEnum.PodcastEpisode && + r.id !== Number(episodeId), + ), + ) ?? [] + const duration = podcastEpisode?.duration + ? Math.round(moment.duration(podcastEpisode.duration).asMinutes()) + : null + + const date = episode?.last_modified + ? formatDate(episode.last_modified, "MMM D, YYYY") + : null + + const topics = episode?.topics?.map((t) => t.name).filter(Boolean) ?? [] + const topicString = topics?.join("\u00A0\u00A0\u00A0\u00A0") + const metaParts = [duration ? `${duration} min` : null, date].filter(Boolean) + + const getAudioUrl = (ep: LearningResource): string | null => { + if (ep.resource_type !== ResourceTypeEnum.PodcastEpisode) return null + const candidate = + ep.podcast_episode?.audio_url ?? ep.podcast_episode?.episode_link + return candidate?.trim() ? candidate : null + } + + const isCurrentEpisodePlaying = + !!episode && playingEpisode?.id === episode.id && isAudioPlaying + + const handlePlay = () => { + if (!episode) return + if (playingEpisode?.id === episode.id) { + if (isAudioPlaying) { + playerRef.current?.pause() + } else { + playerRef.current?.resume() + } + } else if (getAudioUrl(episode)) { + setPlayingEpisode(episode) + } + } + + const currentTrack: PodcastTrack | null = playingEpisode + ? (() => { + const audioUrl = getAudioUrl(playingEpisode) + if (!audioUrl) return null + return { + audioUrl, + title: playingEpisode.title || "Untitled Episode", + podcastName: podcast?.title || "Podcast", + } + })() + : null + + useEffect(() => { + const root = document.documentElement + if (currentTrack) { + const height = isMobile ? PLAYER_HEIGHT.mobile : PLAYER_HEIGHT.desktop + root.style.setProperty("--mit-player-height", `${height}px`) + } else { + root.style.removeProperty("--mit-player-height") + } + return () => { + root.style.removeProperty("--mit-player-height") + } + }, [currentTrack, isMobile]) + + const podcastHref = podcastId ? podcastPageView(podcastId) : "/" + + if (!showPodcastDetailPage) { + return flagsLoaded ? notFound() : null + } + return ( + <> + + + + + + + + + {podcast?.title && ( + {podcast.title} + )} + + {episode?.title ?? ""} + + {metaParts.length > 0 && ( + + {metaParts.join(" . ")} + {!isMobile && . {topicString}} + + )} + {isMobile && {topicString}} + {episode && ( + : + } + disabled={!episode || !getAudioUrl(episode)} + > + {isCurrentEpisodePlaying ? "Pause Episode" : "Play Episode"} + + )} + + {episode?.description && ( + + )} + + + {episodes && episodes.length > 0 && ( + + + More from {podcast?.title ?? "Podcast"} + + + + {episodes.map((episode) => ( + { + if (!getAudioUrl(ep)) return + if (playingEpisode?.id === ep.id) { + playerRef.current?.resume() + } else { + setPlayingEpisode(ep) + } + }} + onPauseClick={() => playerRef.current?.pause()} + isPlayable={Boolean(getAudioUrl(episode))} + isEpisodePage + /> + ))} + + {podcastId && ( + View all episodes → + )} + + )} + + + {currentTrack && ( + setPlayingEpisode(null)} + onPlayStateChange={setIsAudioPlaying} + /> + )} + + ) +} diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx index ec5cd6597b..5e082a7d18 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx @@ -1,6 +1,13 @@ "use client" -import React, { useRef, useState, useEffect, useCallback } from "react" +import React, { + useRef, + useState, + useEffect, + useCallback, + forwardRef, + useImperativeHandle, +} from "react" import { styled, Typography, LoadingSpinner } from "ol-components" import { RiPlayCircleLine, @@ -21,6 +28,11 @@ export type PodcastTrack = { podcastName: string } +export type PodcastPlayerHandle = { + pause: () => void + resume: () => void +} + type PodcastPlayerProps = { track: PodcastTrack onClose: () => void @@ -42,7 +54,7 @@ const PlayerShell = styled.div(({ theme }) => ({ gap: "24px", padding: "32px", background: theme.custom.colors.white, - borderTop: `2px solid ${theme.custom.colors.mitRed}`, + borderTop: `2px solid ${theme.custom.colors.red}`, boxShadow: "0 -4px 16px rgba(0,0,0,0.12)", [theme.breakpoints.down("sm")]: { gridTemplateColumns: "minmax(0, 1fr) auto", @@ -94,7 +106,7 @@ const IconButton = styled.button(({ theme }) => ({ display: "flex", alignItems: "center", color: theme.custom.colors.silverGray, - "&:hover": { color: theme.custom.colors.mitRed }, + "&:hover": { color: theme.custom.colors.red }, "& svg": { width: "24px", height: "24px", @@ -114,16 +126,27 @@ const PlayPauseButton = styled.button(({ theme }) => ({ border: "none", cursor: "pointer", padding: 0, + // Fixed size + overflow:hidden keeps the spinner clipped inside the button. + // The spinner is absolutely centered; play/pause icons fill the same area. + position: "relative", display: "flex", alignItems: "center", - color: theme.custom.colors.mitRed, + justifyContent: "center", + width: "64px", + height: "64px", + flexShrink: 0, + overflow: "hidden", + color: theme.custom.colors.red, "&:hover": { opacity: 0.8 }, - "& svg": { + // Target only direct SVG children (Remix icons) — not the spinner's SVG. + "& > svg": { width: "64px", height: "64px", }, [theme.breakpoints.down("sm")]: { - "& svg": { + width: "56px", + height: "56px", + "& > svg": { width: "56px", height: "56px", }, @@ -157,6 +180,13 @@ const PodcastName = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +const PodcastPlayerLoader = styled(LoadingSpinner)({ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + const ProgressWrapper = styled.div(({ theme }) => ({ gridArea: "progress", display: "flex", @@ -179,20 +209,20 @@ const ProgressRange = styled.input<{ percent: number }>( outline: "none", border: "none", padding: 0, - background: `linear-gradient(to right, ${theme.custom.colors.mitRed} ${percent}%, ${theme.custom.colors.lightGray2} ${percent}%)`, + background: `linear-gradient(to right, ${theme.custom.colors.red} ${percent}%, ${theme.custom.colors.lightGray2} ${percent}%)`, "&::-webkit-slider-thumb": { WebkitAppearance: "none", width: "14px", height: "14px", borderRadius: "50%", - background: theme.custom.colors.mitRed, + background: theme.custom.colors.red, cursor: "pointer", }, "&::-moz-range-thumb": { width: "14px", height: "14px", borderRadius: "50%", - background: theme.custom.colors.mitRed, + background: theme.custom.colors.red, border: "none", cursor: "pointer", }, @@ -211,8 +241,8 @@ const SpeedButton = styled.button(({ theme }) => ({ color: theme.custom.colors.darkGray2, flexShrink: 0, "&:hover": { - borderColor: theme.custom.colors.mitRed, - color: theme.custom.colors.mitRed, + borderColor: theme.custom.colors.red, + color: theme.custom.colors.red, }, [theme.breakpoints.down("sm")]: { justifySelf: "end", @@ -229,7 +259,15 @@ const CloseButton = styled.button(({ theme }) => ({ alignItems: "center", color: theme.custom.colors.darkGray2, flexShrink: 0, - "&:hover": { color: theme.custom.colors.mitRed }, + width: "32px", + height: "32px", + "&:hover": { + backgroundColor: theme.custom.colors.red, + color: theme.custom.colors.white, + alignItems: "center", + justifyContent: "center", + borderRadius: "4px", + }, justifySelf: "end", })) @@ -245,216 +283,235 @@ const formatTime = (seconds: number): string => { // ─── Component ──────────────────────────────────────────────────────────────── -const PodcastPlayer = ({ - track, - onClose, - onPlayStateChange, -}: PodcastPlayerProps) => { - const hasAudioSource = Boolean(track.audioUrl.trim()) - const audioRef = useRef(null) - const isPlayPendingRef = useRef(false) - const playAttemptIdRef = useRef(0) - const [isPlaying, setIsPlaying] = useState(false) - const [isBuffering, setIsBuffering] = useState(true) - const [isPlayPending, setIsPlayPending] = useState(false) - const [currentTime, setCurrentTime] = useState(0) - const [duration, setDuration] = useState(0) - const [speedIndex, setSpeedIndex] = useState(1) // default 1x - const speedIndexRef = useRef(1) - - const startPlayback = useCallback(async () => { - if (!hasAudioSource || isPlayPendingRef.current) return - - const audio = audioRef.current - if (!audio) return - - const attemptId = ++playAttemptIdRef.current - isPlayPendingRef.current = true - setIsPlayPending(true) - - try { - await audio.play() - if (playAttemptIdRef.current === attemptId) { - setIsPlaying(true) +const PodcastPlayer = forwardRef( + ({ track, onClose, onPlayStateChange }, ref) => { + const hasAudioSource = Boolean(track.audioUrl.trim()) + const audioRef = useRef(null) + const isPlayPendingRef = useRef(false) + const playAttemptIdRef = useRef(0) + const [isPlaying, setIsPlaying] = useState(false) + const [isBuffering, setIsBuffering] = useState(true) + const [isPlayPending, setIsPlayPending] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [speedIndex, setSpeedIndex] = useState(1) // default 1x + const speedIndexRef = useRef(1) + + const startPlayback = useCallback(async () => { + if (!hasAudioSource || isPlayPendingRef.current) return + + const audio = audioRef.current + if (!audio) return + + const attemptId = ++playAttemptIdRef.current + isPlayPendingRef.current = true + setIsPlayPending(true) + + try { + await audio.play() + if (playAttemptIdRef.current === attemptId) { + setIsPlaying(true) + } + } catch { + if (playAttemptIdRef.current === attemptId) { + setIsPlaying(false) + } + } finally { + if (playAttemptIdRef.current === attemptId) { + isPlayPendingRef.current = false + setIsPlayPending(false) + } } - } catch { - if (playAttemptIdRef.current === attemptId) { - setIsPlaying(false) + }, [hasAudioSource]) + + // Auto-play when a new track is loaded + useEffect(() => { + // Invalidate any in-flight play attempt from a previous track. + playAttemptIdRef.current += 1 + isPlayPendingRef.current = false + setIsPlayPending(false) + + setCurrentTime(0) + setDuration(0) + setIsPlaying(false) + setIsBuffering(hasAudioSource) + + if (!hasAudioSource) { + return } - } finally { - if (playAttemptIdRef.current === attemptId) { - isPlayPendingRef.current = false - setIsPlayPending(false) + + const audio = audioRef.current + if (!audio) return + audio.load() + audio.playbackRate = SPEED_OPTIONS[speedIndexRef.current] + void startPlayback() + }, [track.audioUrl, hasAudioSource, startPlayback]) + + useImperativeHandle( + ref, + () => ({ + pause: () => { + const audio = audioRef.current + if (audio) { + audio.pause() + setIsPlaying(false) + } + }, + resume: () => { + void startPlayback() + }, + }), + [startPlayback], + ) + + const handlePlayPause = async () => { + if (!hasAudioSource) return + + const audio = audioRef.current + if (!audio) return + + if (isPlaying) { + audio.pause() + setIsPlaying(false) + } else { + void startPlayback() } } - }, [hasAudioSource]) - - // Auto-play when a new track is loaded - useEffect(() => { - // Invalidate any in-flight play attempt from a previous track. - playAttemptIdRef.current += 1 - isPlayPendingRef.current = false - setIsPlayPending(false) - - setCurrentTime(0) - setDuration(0) - setIsPlaying(false) - setIsBuffering(hasAudioSource) - - if (!hasAudioSource) { - return - } - const audio = audioRef.current - if (!audio) return - audio.load() - audio.playbackRate = SPEED_OPTIONS[speedIndexRef.current] - void startPlayback() - }, [track.audioUrl, hasAudioSource, startPlayback]) + useEffect(() => { + onPlayStateChange?.(isPlaying) + }, [isPlaying, onPlayStateChange]) + + const handleSkip = (seconds: number) => { + const audio = audioRef.current + if (!audio) return + audio.currentTime = Math.max( + 0, + Math.min(audio.currentTime + seconds, duration), + ) + } - const handlePlayPause = async () => { - if (!hasAudioSource) return + const handleSpeedCycle = () => { + const nextIndex = (speedIndex + 1) % SPEED_OPTIONS.length + speedIndexRef.current = nextIndex + setSpeedIndex(nextIndex) + if (audioRef.current) { + audioRef.current.playbackRate = SPEED_OPTIONS[nextIndex] + } + } - const audio = audioRef.current - if (!audio) return + const handleSeekKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return - if (isPlaying) { - audio.pause() - setIsPlaying(false) - } else { - void startPlayback() + event.preventDefault() + handleSkip(event.key === "ArrowRight" ? 5 : -5) } - } - - useEffect(() => { - onPlayStateChange?.(isPlaying) - }, [isPlaying, onPlayStateChange]) - - const handleSkip = (seconds: number) => { - const audio = audioRef.current - if (!audio) return - audio.currentTime = Math.max( - 0, - Math.min(audio.currentTime + seconds, duration), + + const percent = duration ? (currentTime / duration) * 100 : 0 + return ( + <> + {/* Shared audio element */} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +