diff --git a/.env b/.env index bb19c65f..d8e14a1c 100644 --- a/.env +++ b/.env @@ -4,6 +4,7 @@ BASE_URL='' CREDENTIALS_BASE_URL='' CSRF_TOKEN_API_PATH='' ECOMMERCE_BASE_URL='' +LEARNING_BASE_URL='' LANGUAGE_PREFERENCE_COOKIE_NAME='' LMS_BASE_URL='' LOGIN_URL='' diff --git a/.env.development b/.env.development index bba898eb..c65e5a0c 100644 --- a/.env.development +++ b/.env.development @@ -5,6 +5,7 @@ BASE_URL='http://localhost:1998' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' +LEARNING_BASE_URL='http://localhost:2000' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' LOGIN_URL='http://localhost:18000/login' diff --git a/.env.test b/.env.test index a420b881..6f7f8038 100644 --- a/.env.test +++ b/.env.test @@ -3,6 +3,7 @@ BASE_URL='http://localhost:1998' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' +LEARNING_BASE_URL='http://localhost:2000' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' LOGIN_URL='http://localhost:18000/login' diff --git a/.stylelintrc.json b/.stylelintrc.json index 9c783ec9..16111ed9 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -31,6 +31,7 @@ "ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"] }], "alpha-value-notation": "number", - "string-quotes": "double" + "string-quotes": "double", + "no-missing-end-of-source-newline": true } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 163db0f1..c23c30d3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,10 @@ -import { mockCourseListSearchResponse } from './__mocks__'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import { mockCourseListSearchResponse, mockCourseAboutResponse } from './__mocks__'; import messages from './catalog/messages'; import { useCourseListSearch } from './data/course-list-search/hooks'; +import { useCourseAboutData } from './course-about/data/hooks'; +import courseAboutIntroMessages from './course-about/course-intro/messages'; import { render, within, waitFor, screen, } from './setupTest'; @@ -20,8 +24,9 @@ jest.mock('./data/course-list-search/hooks', () => ({ useCourseListSearch: jest.fn(), })); -jest.mock('./header/hooks/useMenuItems', () => ({ - useMenuItems: jest.fn(() => ([])), +jest.mock('./course-about/data/hooks', () => ({ + useCourseAboutData: jest.fn(), + useEnrollment: jest.fn(() => jest.fn()), })); jest.mock('./header/hooks/useMenuItems', () => ({ @@ -29,6 +34,7 @@ jest.mock('./header/hooks/useMenuItems', () => ({ })); const mockCourseListSearch = useCourseListSearch as jest.Mock; +const mockCourseAbout = useCourseAboutData as jest.Mock; jest.mock('@edx/frontend-platform/react', () => ({ AppProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -42,9 +48,15 @@ jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: () =>
, })); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + describe('App', () => { beforeEach(() => { document.body.innerHTML = ''; + (getAuthenticatedUser as jest.Mock).mockReturnValue(null); + jest.clearAllMocks(); }); mockCourseListSearch.mockReturnValue({ @@ -53,6 +65,12 @@ describe('App', () => { isError: false, }); + mockCourseAbout.mockReturnValue({ + data: mockCourseAboutResponse, + isLoading: false, + isError: false, + }); + it('renders HomePage on "/" route', async () => { window.testHistory = [ROUTES.HOME]; @@ -89,11 +107,22 @@ describe('App', () => { }); }); - it('renders CourseAboutPage on "/courses/some-course-id/about"', () => { + it('renders CourseAboutPage on "/courses/some-course-id/about"', async () => { window.testHistory = [ROUTES.COURSE_ABOUT]; + const mockUser = { username: 'testuser' }; + (getAuthenticatedUser as jest.Mock).mockReturnValue(mockUser); render(); - expect(screen.getByTestId('course-about-page')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument(); + }); + + expect(screen.getByRole('heading', { name: mockCourseAboutResponse.name })).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.org)).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.shortDescription)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: courseAboutIntroMessages.enrollNowBtn.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: mockCourseAboutResponse.name })).toBeInTheDocument(); }); it('renders NotFoundPage on unknown route', () => { diff --git a/src/__mocks__/courseAbout.ts b/src/__mocks__/courseAbout.ts new file mode 100644 index 00000000..03a6051a --- /dev/null +++ b/src/__mocks__/courseAbout.ts @@ -0,0 +1,97 @@ +export const mockCourseAboutResponse = { + accessExpiration: null, + contentTypeGatingEnabled: false, + courseGoals: { + selectedGoal: null, + weeklyLearningGoalEnabled: false, + }, + effort: null, + end: null, + enrollment: { + mode: null, + isActive: false, + }, + enrollmentStart: null, + enrollmentEnd: null, + entranceExamData: { + entranceExamCurrentScore: 0, + entranceExamEnabled: false, + entranceExamId: '', + entranceExamMinimumScorePct: 0.65, + entranceExamPassed: true, + }, + id: 'course-v1:openedx+123+2024', + license: null, + language: 'en', + media: { + courseImage: { + uri: '/asset-v1:openedx+123+2024+type@asset+block@494a0bae6c7f4aef47b5c0f7d85414b49371151e82646cbbbc22289b4c100c5f.jpg', + }, + courseVideo: { + uri: 'https://youtube.com/watch?v=test123', + }, + image: { + raw: 'http://local.openedx.io:8000/asset-v1:openedx+123+2024+type@asset+block@494a0bae6c7f4aef47b5c0f7d85414b49371151e82646cbbbc22289b4c100c5f.jpg', + small: 'http://local.openedx.io:8000/asset-v1:openedx+123+2024+type@asset+block@494a0bae6c7f4aef47b5c0f7d85414b49371151e82646cbbbc22289b4c100c5f.jpg', + large: 'http://local.openedx.io:8000/asset-v1:openedx+123+2024+type@asset+block@494a0bae6c7f4aef47b5c0f7d85414b49371151e82646cbbbc22289b4c100c5f.jpg', + }, + }, + name: 'Test 2', + offer: null, + org: 'openedx', + relatedPrograms: null, + shortDescription: 'The first MOOC to teach positive psychology. Learn science-based principles and practices for a happy, meaningful life.', + start: '2030-01-01T00:00:00Z', + startDisplay: null, + startType: 'empty', + pacing: 'instructor', + userTimezone: null, + showCalculator: false, + canAccessProctoredExams: false, + notes: { + enabled: false, + visible: true, + }, + marketingUrl: null, + celebrations: { + firstSection: false, + streakLengthToCelebrate: null, + streakDiscountEnabled: false, + weeklyGoal: false, + }, + userHasPassingGrade: false, + courseExitPageIsActive: false, + certificateData: null, + verifyIdentityUrl: null, + verificationStatus: 'none', + linkedinAddToProfileUrl: null, + isIntegritySignatureEnabled: false, + userNeedsIntegritySignature: false, + learningAssistantEnabled: false, + showCoursewareLink: false, + isCourseFull: false, + canEnroll: true, + invitationOnly: false, + isShibCourse: false, + allowAnonymous: false, + ecommerceCheckout: false, + singlePaidMode: {}, + ecommerceCheckoutLink: 'http://example.com/checkout', + courseImageUrls: [ + 'raw', + 'small', + 'large', + ], + startDateIsStillDefault: true, + advertisedStart: null, + coursePrice: 'Free', + preRequisiteCourses: [], + sidebarHtmlEnabled: false, + courseAboutSectionHtml: null, + aboutSidebarHtml: null, + displayNumberWithDefault: '123', + displayOrgWithDefault: 'openedx', + overview: '
Course overview content
', + ocwLinks: [], + prerequisites: [], +}; diff --git a/src/__mocks__/index.ts b/src/__mocks__/index.ts index 819b062a..a673c753 100644 --- a/src/__mocks__/index.ts +++ b/src/__mocks__/index.ts @@ -1,2 +1,3 @@ export { mockCourseResponse } from './course'; export { mockCourseListSearchResponse } from './courseListSearch'; +export { mockCourseAboutResponse } from './courseAbout'; diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index b562c98c..7ae97511 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -47,7 +47,7 @@ const CatalogPage = () => { const totalCourses = courseData?.results?.length ?? 0; return ( - + ({ + getAuthenticatedUser: () => mockGetAuthenticatedUser(), +})); + +jest.mock('./data/api', () => ({ + fetchCourseAboutData: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn(), +})); + +const mockFetchCourseAboutData = fetchCourseAboutData as jest.Mock; +const mockUseLocation = useLocation as jest.Mock; + +describe('CourseAboutPage Integration Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseLocation.mockReturnValue({ + pathname: '/catalog/course-v1:TestX+Test101+2023/about', + }); + mockGetAuthenticatedUser.mockReturnValue(null); + }); + + it('should show loading state when data is being fetched', async () => { + mockFetchCourseAboutData.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('should render course page with all components', async () => { + mockFetchCourseAboutData.mockReturnValue(mockCourseAboutResponse); + render(); + + await waitFor(() => { + expect(screen.getByText(mockCourseAboutResponse.name)).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.displayOrgWithDefault)).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.shortDescription)).toBeInTheDocument(); + + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + expect(screen.getByAltText(mockCourseAboutResponse.name)).toBeInTheDocument(); + }); + }); + + it('should handle course with video correctly', async () => { + const courseWithVideo = { + ...mockCourseAboutResponse, + media: { + ...mockCourseAboutResponse.media, + courseVideo: { + uri: 'https://www.youtube.com/watch?v=test123', + }, + }, + }; + + mockFetchCourseAboutData.mockReturnValue(courseWithVideo); + + render(); + + await waitFor(() => { + const videoButton = screen.getByLabelText(courseMediaMessages.playCourseIntroductionVideo.defaultMessage); + expect(videoButton).toBeInTheDocument(); + }); + + const videoButton = screen.getByLabelText(courseMediaMessages.playCourseIntroductionVideo.defaultMessage); + + await userEvent.click(videoButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByTitle(genericMessages.videoIframeTitle.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should handle course without video correctly', async () => { + const courseWithoutVideo = { + ...mockCourseAboutResponse, + media: { + ...mockCourseAboutResponse.media, + courseVideo: { + uri: null, + }, + }, + }; + + mockFetchCourseAboutData.mockReturnValue(courseWithoutVideo); + + render(); + + await waitFor(() => { + expect(screen.queryByLabelText( + courseMediaMessages.playCourseIntroductionVideo.defaultMessage, + )).not.toBeInTheDocument(); + }); + }); + + it('should display enrollment button for non-enrolled user', async () => { + const courseData = { + ...mockCourseAboutResponse, + enrollment: { isActive: false }, + canEnroll: true, + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { + name: messages.enrollNowBtn.defaultMessage, + })).toBeInTheDocument(); + }); + }); + + it('should display enrolled status for enrolled user', async () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' }); + + const courseData = { + ...mockCourseAboutResponse, + enrollment: { isActive: true }, + showCoursewareLink: true, + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + + render(); + + await waitFor(() => { + expect(screen.getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should handle course full scenario', async () => { + const courseData = { + ...mockCourseAboutResponse, + isCourseFull: true, + canEnroll: false, + }; + + mockFetchCourseAboutData.mockReturnValue(courseData); + + render(); + + await waitFor(() => { + expect(screen.getByText(messages.statusMessageFull.defaultMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-about/CourseAboutPage.tsx b/src/course-about/CourseAboutPage.tsx index b7704338..69ba070e 100644 --- a/src/course-about/CourseAboutPage.tsx +++ b/src/course-about/CourseAboutPage.tsx @@ -1,6 +1,59 @@ -const CourseAboutPage = () => ( - // TODO: remove it when will start develop page -
-); +import { useLocation } from 'react-router'; +import { Container, Layout, Alert } from '@openedx/paragon'; +import { ErrorPage } from '@edx/frontend-platform/react'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { Loading } from '@src/generic'; +import CourseAboutIntroSlot from '@src/plugin-slots/CourseAboutIntroSlot'; +import CourseAboutCourseMediaSlot from '@src/plugin-slots/CourseAboutCourseMediaSlot'; +import { useCourseAboutData } from './data/hooks'; +import messages from './messages'; + +export const GRID_LAYOUT = { + xs: [{ span: 12 }, { span: 'auto' }], + xl: [{ span: 9 }, { span: 3 }], +}; + +const CourseAboutPage = () => { + const intl = useIntl(); + const courseId = useLocation().pathname.split('/')[2]; + const { + data: courseAboutData, + isLoading, + isError, + } = useCourseAboutData(courseId); + + if (isLoading) { + return ; + } + + if (isError) { + return ( + + + + + + ); + } + + return ( + + + + + + + + + + + ); +}; export default CourseAboutPage; diff --git a/src/course-about/course-intro/CourseIntro.test.tsx b/src/course-about/course-intro/CourseIntro.test.tsx new file mode 100644 index 00000000..f7846aff --- /dev/null +++ b/src/course-about/course-intro/CourseIntro.test.tsx @@ -0,0 +1,90 @@ +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +import { + render, screen, waitFor, userEvent, +} from '@src/setupTest'; +import { mockCourseAboutResponse } from '@src/__mocks__'; +import { useEnrollment } from '@src/course-about/data/hooks'; +import { CourseIntro } from './CourseIntro'; +import messages from './messages'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('@src/course-about/data/hooks', () => ({ + useEnrollment: jest.fn(), +})); + +describe('CourseIntro', () => { + const mockEnrollAndRedirect = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (getAuthenticatedUser as jest.Mock).mockReturnValue(null); + (useEnrollment as jest.Mock).mockReturnValue(mockEnrollAndRedirect); + }); + + it('renders course information correctly', () => { + render(); + + expect(screen.getByText(mockCourseAboutResponse.name)).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.org)).toBeInTheDocument(); + expect(screen.getByText(mockCourseAboutResponse.shortDescription)).toBeInTheDocument(); + }); + + it('renders enrollment button for eligible users', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: messages.enrollNowBtn.defaultMessage })).toBeInTheDocument(); + }); + }); + + it('handles enrollment action correctly', async () => { + mockEnrollAndRedirect.mockResolvedValueOnce(undefined); + render(); + + const enrollButton = await screen.findByRole('button', { name: messages.enrollNowBtn.defaultMessage }); + userEvent.click(enrollButton); + + await waitFor(() => { + expect(mockEnrollAndRedirect).toHaveBeenCalledWith( + mockCourseAboutResponse.id, + `${getConfig().LMS_BASE_URL}/dashboard`, + ); + }); + }); + + it('renders enrolled status for authenticated active users', async () => { + (getAuthenticatedUser as jest.Mock).mockReturnValue({ username: 'testuser' }); + const enrolledCourseData = { + ...mockCourseAboutResponse, + enrollment: { isActive: true }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('handles authenticated user correctly', async () => { + const mockUser = { username: 'testuser' }; + (getAuthenticatedUser as jest.Mock).mockReturnValue(mockUser); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { + name: messages.enrollNowBtn.defaultMessage, + })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-about/course-intro/CourseIntro.tsx b/src/course-about/course-intro/CourseIntro.tsx new file mode 100644 index 00000000..addcd3ac --- /dev/null +++ b/src/course-about/course-intro/CourseIntro.tsx @@ -0,0 +1,50 @@ +import { Card, Container } from '@openedx/paragon'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import type { CourseAboutDataPartial } from '../types'; +import { useEnrollmentActions, useEnrollmentStatus } from './hooks'; + +export const CourseIntro = ({ courseAboutData }: { courseAboutData: CourseAboutDataPartial }) => { + const authenticatedUser = getAuthenticatedUser(); + + const { + id: courseId, + displayOrgWithDefault: courseOrg, + name: courseName, + shortDescription, + ecommerceCheckoutLink, + } = courseAboutData; + + const { + enrollmentError, + isEnrollmentPending, + handleChangeEnrollment, + handleEcommerceCheckout, + } = useEnrollmentActions({ courseId, ecommerceCheckoutLink }); + + const { renderStatusContent } = useEnrollmentStatus({ + courseAboutData, + enrollmentError, + authenticatedUser, + isEnrollmentPending, + handleChangeEnrollment, + handleEcommerceCheckout, + }); + + return ( + + + {courseName}} + subtitle={courseOrg} + /> + + {shortDescription} + + + {renderStatusContent()} + + + + ); +}; diff --git a/src/course-about/course-intro/components/EnrolledStatus.tsx b/src/course-about/course-intro/components/EnrolledStatus.tsx new file mode 100644 index 00000000..e1e23c32 --- /dev/null +++ b/src/course-about/course-intro/components/EnrolledStatus.tsx @@ -0,0 +1,29 @@ +import { + breakpoints, Button, Stack, useMediaQuery, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { getLearningHomePageUrl } from '../utils'; +import messages from '../messages'; +import { STATUS_MESSAGE_VARIANTS } from '../constants'; +import type { EnrolledStatusTypes } from './types'; +import { StatusMessage } from './StatusMessage'; + +export const EnrolledStatus = ({ showCoursewareLink, courseId }: EnrolledStatusTypes) => { + const intl = useIntl(); + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth }); + + return ( + + + {showCoursewareLink && ( + + )} + + ); +}; diff --git a/src/course-about/course-intro/components/EnrollmentButton.tsx b/src/course-about/course-intro/components/EnrollmentButton.tsx new file mode 100644 index 00000000..454a7be5 --- /dev/null +++ b/src/course-about/course-intro/components/EnrollmentButton.tsx @@ -0,0 +1,27 @@ +import { StatefulButton } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import type { EnrollmentButtonTypes } from './types'; + +export const EnrollmentButton = ({ + onEnroll, + singlePaidMode, + ecommerceCheckout, + isEnrollmentPending, + onEcommerceCheckout, +}: EnrollmentButtonTypes) => { + const intl = useIntl(); + + return ( + 0 ? 'outline-primary' : 'primary'} + onClick={ecommerceCheckout ? onEcommerceCheckout : onEnroll} + state={isEnrollmentPending ? 'pending' : 'default'} + labels={{ + default: intl.formatMessage(messages.enrollNowBtn), + pending: intl.formatMessage(messages.enrollNowBtnPending), + }} + /> + ); +}; diff --git a/src/course-about/course-intro/components/StatusMessage.tsx b/src/course-about/course-intro/components/StatusMessage.tsx new file mode 100644 index 00000000..d7d4e6da --- /dev/null +++ b/src/course-about/course-intro/components/StatusMessage.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import { Stack, Icon } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import { STATUS_MESSAGE_ICONS, STATUS_MESSAGE_VARIANTS } from '../constants'; +import type { StatusMessageTypes } from './types'; + +export const StatusMessage = ({ variant, messageKey }: StatusMessageTypes) => { + const intl = useIntl(); + + const icon = useMemo( + () => STATUS_MESSAGE_ICONS[variant] || STATUS_MESSAGE_ICONS[STATUS_MESSAGE_VARIANTS.INFO], + [variant], + ); + + return ( + + + {intl.formatMessage(messages[messageKey])} + + ); +}; diff --git a/src/course-about/course-intro/components/__tests__/EnrolledStatus.test.tsx b/src/course-about/course-intro/components/__tests__/EnrolledStatus.test.tsx new file mode 100644 index 00000000..11d6a714 --- /dev/null +++ b/src/course-about/course-intro/components/__tests__/EnrolledStatus.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@src/setupTest'; +import messages from '../../messages'; +import { STATUS_MESSAGE_VARIANTS } from '../../constants'; +import { EnrolledStatus } from '../EnrolledStatus'; + +describe('EnrolledStatus', () => { + const defaultProps = { + showCoursewareLink: false, + courseId: 'test-course-123', + }; + + it('renders enrollment success status message', () => { + render(); + expect(screen.getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + }); + + it('does not render courseware link button when showCoursewareLink is false', () => { + render(); + expect(screen.queryByRole('link', { + name: messages.viewCourseBtn.defaultMessage, + })).not.toBeInTheDocument(); + }); + + it('renders courseware link button when showCoursewareLink is true', () => { + render(); + + const viewCourseBtnLink = screen.getByRole('link', { + name: messages.viewCourseBtn.defaultMessage, + }); + expect(viewCourseBtnLink).toHaveAttribute('href', expect.stringContaining(defaultProps.courseId)); + }); + + it('renders status message with success variant', () => { + render(); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.SUCCESS}-500`); + }); + + it('renders both status message and button when showCoursewareLink is true', () => { + render(); + + expect(screen.getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + + const viewCourseBtnLink = screen.getByRole('link', { + name: messages.viewCourseBtn.defaultMessage, + }); + expect(viewCourseBtnLink).toHaveAttribute('href', expect.stringContaining(defaultProps.courseId)); + }); +}); diff --git a/src/course-about/course-intro/components/__tests__/EnrollmentButton.test.tsx b/src/course-about/course-intro/components/__tests__/EnrollmentButton.test.tsx new file mode 100644 index 00000000..bdd4a781 --- /dev/null +++ b/src/course-about/course-intro/components/__tests__/EnrollmentButton.test.tsx @@ -0,0 +1,86 @@ +import { render, userEvent, screen } from '@src/setupTest'; +import messages from '../../messages'; +import { EnrollmentButton } from '../EnrollmentButton'; + +const user = userEvent.setup(); + +describe('EnrollmentButton', () => { + const defaultProps = { + onEnroll: jest.fn(), + singlePaidMode: {}, + ecommerceCheckout: false, + isEnrollmentPending: false, + onEcommerceCheckout: jest.fn(), + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders with default enrollment text', () => { + render(); + expect(screen.getByText(messages.enrollNowBtn.defaultMessage)).toBeInTheDocument(); + }); + + it('shows pending text when enrollment is pending', () => { + render(); + expect(screen.getByRole('button', { + name: messages.enrollNowBtnPending.defaultMessage, + })).toBeInTheDocument(); + }); + + it('calls onEnroll when clicked in default mode', async () => { + render(); + + await user.click(screen.getByText(messages.enrollNowBtn.defaultMessage)); + expect(defaultProps.onEnroll).toHaveBeenCalledTimes(1); + }); + + it('calls onEcommerceCheckout when clicked in ecommerce mode', async () => { + render(); + + await user.click(screen.getByText(messages.enrollNowBtn.defaultMessage)); + expect(defaultProps.onEcommerceCheckout).toHaveBeenCalledTimes(1); + expect(defaultProps.onEnroll).not.toHaveBeenCalled(); + }); + + it('renders button with correct attributes and classes', () => { + render(); + + const enrollNowBtn = screen.getByRole('button', { + name: messages.enrollNowBtn.defaultMessage, + }); + + expect(enrollNowBtn).toHaveAttribute('aria-disabled', 'false'); + expect(enrollNowBtn).toHaveAttribute('aria-live', 'assertive'); + expect(enrollNowBtn).toHaveClass('pgn__stateful-btn'); + expect(enrollNowBtn).toHaveClass('btn'); + expect(enrollNowBtn).toHaveClass('btn-primary'); + }); + + it('renders button with correct attributes and classes with singlePaidMode', () => { + render(); + + const enrollNowBtn = screen.getByRole('button', { + name: messages.enrollNowBtn.defaultMessage, + }); + + expect(enrollNowBtn).toHaveAttribute('aria-disabled', 'false'); + expect(enrollNowBtn).toHaveAttribute('aria-live', 'assertive'); + expect(enrollNowBtn).toHaveClass('pgn__stateful-btn'); + expect(enrollNowBtn).toHaveClass('btn'); + expect(enrollNowBtn).toHaveClass('btn-outline-primary'); + }); + + it('handles keyboard interaction for accessibility', async () => { + render(); + + const enrollNowBtn = screen.getByRole('button', { + name: messages.enrollNowBtn.defaultMessage, + }); + + await user.tab(); + expect(enrollNowBtn).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(defaultProps.onEnroll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-about/course-intro/components/__tests__/StatusMessage.test.tsx b/src/course-about/course-intro/components/__tests__/StatusMessage.test.tsx new file mode 100644 index 00000000..eccfc59b --- /dev/null +++ b/src/course-about/course-intro/components/__tests__/StatusMessage.test.tsx @@ -0,0 +1,37 @@ +import { render, within, screen } from '@src/setupTest'; +import { STATUS_MESSAGE_VARIANTS } from '../../constants'; +import messages from '../../messages'; +import { StatusMessage } from '../StatusMessage'; + +const renderStatusMessage = ( + variant: typeof STATUS_MESSAGE_VARIANTS[keyof typeof STATUS_MESSAGE_VARIANTS], + messageKey: string, +) => render( + , +); + +describe('StatusMessage', () => { + it('renders with success variant and correct message', () => { + renderStatusMessage(STATUS_MESSAGE_VARIANTS.SUCCESS, 'statusMessageEnrolled'); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.SUCCESS}-500`); + expect(within(statusMessage).getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + }); + + it('renders with info variant and correct message', () => { + renderStatusMessage(STATUS_MESSAGE_VARIANTS.INFO, 'statusMessageFull'); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.INFO}-500`); + expect(within(statusMessage).getByText(messages.statusMessageFull.defaultMessage)).toBeInTheDocument(); + }); + + it('renders with danger variant and correct message', () => { + renderStatusMessage(STATUS_MESSAGE_VARIANTS.DANGER, 'statusMessageEnrolled'); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.DANGER}-500`); + expect(within(statusMessage).getByText(messages.statusMessageEnrolled.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/course-about/course-intro/components/index.ts b/src/course-about/course-intro/components/index.ts new file mode 100644 index 00000000..cac4d1b3 --- /dev/null +++ b/src/course-about/course-intro/components/index.ts @@ -0,0 +1,3 @@ +export { EnrollmentButton } from './EnrollmentButton'; +export { EnrolledStatus } from './EnrolledStatus'; +export { StatusMessage } from './StatusMessage'; diff --git a/src/course-about/course-intro/components/types.ts b/src/course-about/course-intro/components/types.ts new file mode 100644 index 00000000..e428c66d --- /dev/null +++ b/src/course-about/course-intro/components/types.ts @@ -0,0 +1,21 @@ +import { STATUS_MESSAGE_VARIANTS } from '../constants'; + +export type StatusMessageVariant = typeof STATUS_MESSAGE_VARIANTS[keyof typeof STATUS_MESSAGE_VARIANTS]; + +export interface EnrollmentButtonTypes { + singlePaidMode: {}; + ecommerceCheckout: boolean; + isEnrollmentPending: boolean; + onEnroll: () => void; + onEcommerceCheckout: () => void; +} + +export interface EnrolledStatusTypes { + showCoursewareLink: boolean; + courseId: string; +} + +export interface StatusMessageTypes { + variant: StatusMessageVariant; + messageKey: string; +} diff --git a/src/course-about/course-intro/constants.ts b/src/course-about/course-intro/constants.ts new file mode 100644 index 00000000..0a211a39 --- /dev/null +++ b/src/course-about/course-intro/constants.ts @@ -0,0 +1,15 @@ +import { + Info as InfoIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, +} from '@openedx/paragon/icons'; + +export const STATUS_MESSAGE_VARIANTS = { + SUCCESS: 'success', + INFO: 'info', + DANGER: 'danger', +} as const; + +export const STATUS_MESSAGE_ICONS = { + [STATUS_MESSAGE_VARIANTS.SUCCESS]: CheckCircleIcon, + [STATUS_MESSAGE_VARIANTS.INFO]: InfoIcon, + [STATUS_MESSAGE_VARIANTS.DANGER]: ErrorIcon, +} as const; diff --git a/src/course-about/course-intro/course-media/CourseMedia.scss b/src/course-about/course-intro/course-media/CourseMedia.scss new file mode 100644 index 00000000..b7a4c5ff --- /dev/null +++ b/src/course-about/course-intro/course-media/CourseMedia.scss @@ -0,0 +1,35 @@ +.course-media-wrapper { + .course-media-image { + height: var(--catalog-course-about-page-course-media-image-height, 15.75rem); + object-fit: cover; + } + + .pgn__icon { + height: var(--catalog-course-about-page-course-media-play-icon-height, 3.125rem); + width: var(--catalog-course-about-page-course-media-play-icon-width, 3.125rem); + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + svg:hover { + opacity: var(--catalog-course-about-page-course-media-play-icon-hover-opacity, .9); + } + } + + @media (--pgn-size-breakpoint-max-width-xl) { + margin-bottom: var(--pgn-spacing-spacer-2-5); + } +} + +.course-about-intro-wrapper { + @media (--pgn-size-breakpoint-max-width-xl) { + .row { + flex-direction: column-reverse; + align-items: center; + + .course-media-wrapper { + margin-bottom: var(--pgn-spacing-spacer-base); + } + } + } +} diff --git a/src/course-about/course-intro/course-media/CourseMedia.tsx b/src/course-about/course-intro/course-media/CourseMedia.tsx new file mode 100644 index 00000000..6c2efe44 --- /dev/null +++ b/src/course-about/course-intro/course-media/CourseMedia.tsx @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; +import { useToggle } from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; + +import noCourseImg from '@src/assets/images/no-course-image.svg'; + +import { CourseAboutIntroVideoModalSlot, CourseAboutIntroVideoButtonSlot } from '@src/plugin-slots/CourseAboutIntroVideoSlots'; +import CourseAboutCourseImageSlot from '@src/plugin-slots/CourseAboutCourseImageSlot'; +import { extractYouTubeVideoId, getMediaUris } from './utils'; +import type { CourseMediaTypes } from './types'; + +const CourseMedia = ({ courseAboutData }: CourseMediaTypes) => { + const [isOpenVideoModal, openVideoModal, closeVideoModal] = useToggle(false); + const { imageUrl, videoUrl } = getMediaUris(courseAboutData); + const videoId = useMemo(() => extractYouTubeVideoId(videoUrl), [videoUrl]); + + const imgSrc = imageUrl ? `${getConfig().LMS_BASE_URL}${imageUrl}` : noCourseImg; + + return videoId ? ( + <> + + + + ) : ; +}; + +export default CourseMedia; diff --git a/src/course-about/course-intro/course-media/__tests__/CourseMedia.test.tsx b/src/course-about/course-intro/course-media/__tests__/CourseMedia.test.tsx new file mode 100644 index 00000000..c2a23a99 --- /dev/null +++ b/src/course-about/course-intro/course-media/__tests__/CourseMedia.test.tsx @@ -0,0 +1,112 @@ +import { getConfig } from '@edx/frontend-platform'; +// Import fireEvent directly for simulating browser events that userEvent cannot handle +import { fireEvent } from '@testing-library/react'; + +import noCourseImg from '@src/assets/images/no-course-image.svg'; + +import { + userEvent, render, screen, within, +} from '@src/setupTest'; +import { mockCourseAboutResponse } from '@src/__mocks__'; +import CourseMedia from '../CourseMedia'; +import messages from '../messages'; + +describe('CourseMedia', () => { + const mockCourseData = { + name: mockCourseAboutResponse.name, + media: mockCourseAboutResponse.media, + }; + + const defaultProps = { + courseAboutData: mockCourseData, + }; + + it('renders course image with correct attributes', () => { + render(); + + const image = screen.getByAltText(mockCourseData.name); + expect(image).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseAboutResponse.media.courseImage.uri}`); + expect(image).toHaveClass('course-media-image'); + }); + + it('renders video thumbnail when video is available', () => { + render(); + + const videoButton = screen.getByRole('button', { + name: messages.playCourseIntroductionVideo.defaultMessage, + }); + expect(videoButton).toBeInTheDocument(); + }); + + it('renders only image when no video is available', () => { + const courseDataWithoutVideo = { + ...mockCourseData, + media: { + ...mockCourseData.media, + courseVideo: undefined, + }, + }; + + render(); + + expect(screen.getByAltText(mockCourseData.name)).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('opens video modal when clicking video thumbnail', async () => { + const user = userEvent.setup(); + render(); + + const videoButton = screen.getByRole('button', { + name: messages.playCourseIntroductionVideo.defaultMessage, + }); + await user.click(videoButton); + + // Check if VideoModal is rendered + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('uses placeholder image when image fails to load', () => { + render(); + + const image = screen.getByAltText(mockCourseData.name); + fireEvent.error(image); + + expect(image).toHaveAttribute('src', noCourseImg); + }); + + it('uses placeholder image when no image URL is provided', () => { + const courseDataWithoutImage = { + ...mockCourseData, + media: { + ...mockCourseData.media, + courseImage: { + uri: null, + }, + }, + }; + + render(); + + const image = screen.getByAltText(mockCourseData.name); + expect(image).toHaveAttribute('src', noCourseImg); + }); + + it('renders play icon when video is available', () => { + render(); + + const videoButton = screen.getByRole('button', { + name: messages.playCourseIntroductionVideo.defaultMessage, + }); + + const playIcon = within(videoButton).getByRole('img'); + expect(playIcon).toBeInTheDocument(); + }); + + it('constructs correct image URL with LMS base URL', () => { + render(); + + const image = screen.getByAltText(mockCourseData.name); + expect(image).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseAboutResponse.media.courseImage.uri}`); + }); +}); diff --git a/src/course-about/course-intro/course-media/__tests__/utils.test.ts b/src/course-about/course-intro/course-media/__tests__/utils.test.ts new file mode 100644 index 00000000..46dacb55 --- /dev/null +++ b/src/course-about/course-intro/course-media/__tests__/utils.test.ts @@ -0,0 +1,102 @@ +import { mockCourseAboutResponse } from '@src/__mocks__'; +import { extractYouTubeVideoId, getMediaUris } from '../utils'; + +describe('Course Media Utils', () => { + describe('extractYouTubeVideoId', () => { + it('extracts video ID from standard YouTube URL', () => { + expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=abc123')).toBe('abc123'); + }); + + it('extracts video ID from YouTube URL with additional parameters', () => { + expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=abc123&t=30s&feature=share')).toBe('abc123'); + }); + + it('extracts video ID from YouTube URL with hash', () => { + expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=abc123#t=30s')).toBe('abc123'); + }); + + it('returns null for non-YouTube URL', () => { + expect(extractYouTubeVideoId('https://example.com/video')).toBeNull(); + }); + + it('returns null for invalid URL', () => { + expect(extractYouTubeVideoId('not-a-url')).toBeNull(); + }); + + it('returns null for empty URL', () => { + expect(extractYouTubeVideoId('')).toBeNull(); + }); + }); + + describe('getMediaUris', () => { + const mockCourseData = { + name: mockCourseAboutResponse.name, + media: mockCourseAboutResponse.media, + }; + + it('returns correct URIs when both image and video are present', () => { + const result = getMediaUris(mockCourseData); + expect(result).toEqual({ + imageUrl: mockCourseAboutResponse.media.courseImage.uri, + videoUrl: mockCourseAboutResponse.media.courseVideo?.uri, + }); + }); + + it('returns empty string for imageUrl when courseImage is missing', () => { + const dataWithoutImage = { + name: mockCourseAboutResponse.name, + media: { + courseImage: { uri: '' }, + courseVideo: { uri: mockCourseAboutResponse.media.courseVideo?.uri }, + }, + }; + const result = getMediaUris(dataWithoutImage); + expect(result).toEqual({ + imageUrl: '', + videoUrl: mockCourseAboutResponse.media.courseVideo?.uri, + }); + }); + + it('returns empty string for videoUrl when courseVideo is missing', () => { + const dataWithoutVideo = { + name: mockCourseAboutResponse.name, + media: { + courseImage: { uri: mockCourseAboutResponse.media.courseImage.uri }, + }, + }; + const result = getMediaUris(dataWithoutVideo); + expect(result).toEqual({ + imageUrl: mockCourseAboutResponse.media.courseImage.uri, + videoUrl: '', + }); + }); + + it('returns empty strings when media object is empty', () => { + const emptyData = { + name: mockCourseAboutResponse.name, + media: { + courseImage: { uri: '' }, + }, + }; + const result = getMediaUris(emptyData); + expect(result).toEqual({ + imageUrl: '', + videoUrl: '', + }); + }); + + it('handles null values in media object', () => { + const dataWithNulls = { + media: { + courseImage: null, + courseVideo: null, + }, + }; + const result = getMediaUris(dataWithNulls as any); + expect(result).toEqual({ + imageUrl: '', + videoUrl: '', + }); + }); + }); +}); diff --git a/src/course-about/course-intro/course-media/messages.ts b/src/course-about/course-intro/course-media/messages.ts new file mode 100644 index 00000000..ccfbf696 --- /dev/null +++ b/src/course-about/course-intro/course-media/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + playCourseIntroductionVideo: { + id: 'category.course-about.course-intro.course-media.play-course-introduction-video', + defaultMessage: 'Play course introduction video', + description: 'The text for the button to play the course introduction video.', + }, +}); + +export default messages; diff --git a/src/course-about/course-intro/course-media/types.ts b/src/course-about/course-intro/course-media/types.ts new file mode 100644 index 00000000..0bc9b43b --- /dev/null +++ b/src/course-about/course-intro/course-media/types.ts @@ -0,0 +1,10 @@ +import type { CourseMediaPartial } from '../../types'; + +export interface CourseMediaTypes { + courseAboutData: { + name: string; + media: CourseMediaPartial; + }; +} + +export type ExtractYouTubeVideoIdTypes = (url: string) => string | null; diff --git a/src/course-about/course-intro/course-media/utils.ts b/src/course-about/course-intro/course-media/utils.ts new file mode 100644 index 00000000..f0c5dead --- /dev/null +++ b/src/course-about/course-intro/course-media/utils.ts @@ -0,0 +1,17 @@ +import type { CourseMediaTypes, ExtractYouTubeVideoIdTypes } from './types'; + +/** + * Extracts the YouTube video ID from a given URL. + */ +export const extractYouTubeVideoId: ExtractYouTubeVideoIdTypes = (url) => { + const match = url.match(/[?&]v=([^&#]+)/); + return match ? match[1] : null; +}; + +/** + * Returns media URIs (image and video) from course about data. + */ +export const getMediaUris = (courseAboutData: CourseMediaTypes['courseAboutData']) => ({ + imageUrl: courseAboutData.media.courseImage?.uri ?? '', + videoUrl: courseAboutData.media.courseVideo?.uri ?? '', +}); diff --git a/src/course-about/course-intro/hooks/__tests__/useEnrollmentActions.test.tsx b/src/course-about/course-intro/hooks/__tests__/useEnrollmentActions.test.tsx new file mode 100644 index 00000000..dfed468d --- /dev/null +++ b/src/course-about/course-intro/hooks/__tests__/useEnrollmentActions.test.tsx @@ -0,0 +1,112 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; + +import { renderHook, act } from '@src/setupTest'; +import { useEnrollment } from '@src/course-about/data/hooks'; +import { mockCourseAboutResponse } from '@src/__mocks__'; +import { useEnrollmentActions } from '../useEnrollmentActions'; +import type { UseEnrollmentActionsTypes } from '../types'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('@src/course-about/data/hooks', () => ({ + useEnrollment: jest.fn(), +})); + +const renderHookWithWrapper = (props: UseEnrollmentActionsTypes) => renderHook( + () => useEnrollmentActions(props), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, +); + +describe('useEnrollmentActions', () => { + const mockEnrollAndRedirect = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useEnrollment as jest.Mock).mockReturnValue(mockEnrollAndRedirect); + }); + + it('should initialize with default state', () => { + const { result } = renderHookWithWrapper({ + courseId: mockCourseAboutResponse.id, + ecommerceCheckoutLink: mockCourseAboutResponse.ecommerceCheckoutLink, + }); + + expect(result.current.enrollmentError).toBeNull(); + expect(result.current.isEnrollmentPending).toBe(false); + }); + + it('should handle successful enrollment', async () => { + mockEnrollAndRedirect.mockResolvedValueOnce(undefined); + const { result } = renderHookWithWrapper({ + courseId: mockCourseAboutResponse.id, + ecommerceCheckoutLink: mockCourseAboutResponse.ecommerceCheckoutLink, + }); + + await act(async () => { + await result.current.handleChangeEnrollment(); + }); + + expect(mockEnrollAndRedirect).toHaveBeenCalledWith( + mockCourseAboutResponse.id, + `${getConfig().LMS_BASE_URL}/dashboard`, + ); + }); + + it('should handle enrollment error', async () => { + const mockError = new Error('Enrollment failed'); + mockEnrollAndRedirect.mockRejectedValueOnce(mockError); + const { result } = renderHookWithWrapper({ + courseId: mockCourseAboutResponse.id, + ecommerceCheckoutLink: mockCourseAboutResponse.ecommerceCheckoutLink, + }); + + await act(async () => { + await result.current.handleChangeEnrollment(); + }); + + expect(logError).toHaveBeenCalledWith('Failed to enroll in course', mockError); + expect(result.current.isEnrollmentPending).toBe(false); + }); + + it('should handle ecommerce checkout with valid link', () => { + const { result } = renderHookWithWrapper({ + courseId: mockCourseAboutResponse.id, + ecommerceCheckoutLink: mockCourseAboutResponse.ecommerceCheckoutLink, + }); + + const mockAssign = jest.fn(); + Object.defineProperty(window, 'location', { + value: { + assign: mockAssign, + href: '', + }, + configurable: true, + }); + + result.current.handleEcommerceCheckout(); + + expect(mockAssign).toHaveBeenCalledWith(mockCourseAboutResponse.ecommerceCheckoutLink); + expect(logError).not.toHaveBeenCalled(); + }); + + it('should handle ecommerce checkout with missing link', () => { + const { result } = renderHookWithWrapper({ + courseId: mockCourseAboutResponse.id, + ecommerceCheckoutLink: undefined, + }); + + result.current.handleEcommerceCheckout(); + + expect(logError).toHaveBeenCalledWith('Ecommerce checkout link is not available'); + }); +}); diff --git a/src/course-about/course-intro/hooks/__tests__/useEnrollmentStatus.test.tsx b/src/course-about/course-intro/hooks/__tests__/useEnrollmentStatus.test.tsx new file mode 100644 index 00000000..f0e6c133 --- /dev/null +++ b/src/course-about/course-intro/hooks/__tests__/useEnrollmentStatus.test.tsx @@ -0,0 +1,174 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { + cleanup, renderHook, render, screen, +} from '@src/setupTest'; +import { mockCourseAboutResponse } from '@src/__mocks__'; +import { STATUS_MESSAGE_VARIANTS } from '../../constants'; +import { getLearningHomePageUrl } from '../../utils'; +import messages from '../../messages'; +import { useEnrollmentStatus } from '../useEnrollmentStatus'; + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('useEnrollmentStatus', () => { + const mockCourseAboutData = { + ...mockCourseAboutResponse, + showCoursewareLink: true, + }; + + const mockProps = { + courseAboutData: mockCourseAboutData, + enrollmentError: null, + authenticatedUser: null, + isEnrollmentPending: false, + handleChangeEnrollment: jest.fn(), + handleEcommerceCheckout: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('renders enrollment error status message when there is an error', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + enrollmentError: messages.statusMessageEnrollmentError.defaultMessage, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.DANGER}-500`); + expect(statusMessage).toHaveTextContent(messages.statusMessageEnrollmentError.defaultMessage); + }); + + it('renders enrolled status message for authenticated active users', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + authenticatedUser: { username: 'testuser' }, + courseAboutData: { + ...mockCourseAboutData, + enrollment: { isActive: true }, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const statusMessage = screen.getByRole('status'); + + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.SUCCESS}-500`); + expect(statusMessage).toHaveTextContent(messages.statusMessageEnrolled.defaultMessage); + + const viewCourseButton = screen.getByRole('link', { name: messages.viewCourseBtn.defaultMessage }); + expect(viewCourseButton).toHaveAttribute('href', getLearningHomePageUrl(mockCourseAboutResponse.id)); + }); + + it('renders full course status message when course is full', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + courseAboutData: { + ...mockCourseAboutData, + isCourseFull: true, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.INFO}-500`); + expect(statusMessage).toHaveTextContent(messages.statusMessageFull.defaultMessage); + }); + + it('renders invitation only status message when course is invitation only and user cannot enroll', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + courseAboutData: { + ...mockCourseAboutData, + invitationOnly: true, + canEnroll: false, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.INFO}-500`); + expect(statusMessage).toHaveTextContent(messages.statusMessageEnrollmentInvitationOnly.defaultMessage); + }); + + it('renders enrollment closed status message when course is not shib and user cannot enroll', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + courseAboutData: { + ...mockCourseAboutData, + isShibCourse: false, + canEnroll: false, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const statusMessage = screen.getByRole('status'); + expect(statusMessage).toHaveClass(`text-${STATUS_MESSAGE_VARIANTS.INFO}-500`); + expect(statusMessage).toHaveTextContent(messages.statusMessageEnrollmentClosed.defaultMessage); + }); + + it('renders enrollment button for eligible users', () => { + const { result } = renderHook(() => useEnrollmentStatus(mockProps), { wrapper }); + + render(result.current.renderStatusContent()); + + const enrollButton = screen.getByRole('button', { name: messages.enrollNowBtn.defaultMessage }); + expect(enrollButton).toHaveClass('btn-primary'); + expect(enrollButton).toHaveTextContent(messages.enrollNowBtn.defaultMessage); + }); + + it('renders view course button for anonymous users when course allows anonymous access', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + courseAboutData: { + ...mockCourseAboutData, + allowAnonymous: true, + showCoursewareLink: true, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const viewCourseButton = screen.getByRole('link', { name: messages.viewCourseBtn.defaultMessage }); + expect(viewCourseButton).toHaveAttribute('href', getLearningHomePageUrl(mockCourseAboutData.id)); + }); + + it('shows pending state on enrollment button when enrollment is pending', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + isEnrollmentPending: true, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const enrollButton = screen.getByRole('button', { name: messages.enrollNowBtnPending.defaultMessage }); + expect(enrollButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('handles ecommerce checkout mode correctly', () => { + const { result } = renderHook(() => useEnrollmentStatus({ + ...mockProps, + courseAboutData: { + ...mockCourseAboutData, + ecommerceCheckout: true, + }, + }), { wrapper }); + + render(result.current.renderStatusContent()); + + const enrollButton = screen.getByRole('button', { name: messages.enrollNowBtn.defaultMessage }); + expect(enrollButton).toHaveTextContent(messages.enrollNowBtn.defaultMessage); + }); +}); diff --git a/src/course-about/course-intro/hooks/index.ts b/src/course-about/course-intro/hooks/index.ts new file mode 100644 index 00000000..55c2323c --- /dev/null +++ b/src/course-about/course-intro/hooks/index.ts @@ -0,0 +1,2 @@ +export { useEnrollmentActions } from './useEnrollmentActions'; +export { useEnrollmentStatus } from './useEnrollmentStatus'; diff --git a/src/course-about/course-intro/hooks/types.ts b/src/course-about/course-intro/hooks/types.ts new file mode 100644 index 00000000..f093d477 --- /dev/null +++ b/src/course-about/course-intro/hooks/types.ts @@ -0,0 +1,17 @@ +import { User } from '@edx/frontend-platform/auth'; + +import type { CourseAboutDataPartial } from '../../types'; + +export interface UseEnrollmentActionsTypes { + courseId: string; + ecommerceCheckoutLink?: string | null; +} + +export interface UseEnrollmentStatusTypes { + courseAboutData: CourseAboutDataPartial; + authenticatedUser: User; + enrollmentError: string | null; + isEnrollmentPending: boolean; + handleChangeEnrollment: () => void; + handleEcommerceCheckout: () => void; +} diff --git a/src/course-about/course-intro/hooks/useEnrollmentActions.tsx b/src/course-about/course-intro/hooks/useEnrollmentActions.tsx new file mode 100644 index 00000000..279d307c --- /dev/null +++ b/src/course-about/course-intro/hooks/useEnrollmentActions.tsx @@ -0,0 +1,46 @@ +import { useState, useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; + +import { useEnrollment } from '../../data/hooks'; +import messages from '../messages'; +import type { UseEnrollmentActionsTypes } from './types'; + +export const useEnrollmentActions = ({ courseId, ecommerceCheckoutLink }: UseEnrollmentActionsTypes) => { + const intl = useIntl(); + const [enrollmentError, setEnrollmentError] = useState(null); + const [isEnrollmentPending, setIsEnrollmentPending] = useState(false); + + const enrollmentConfig = useMemo(() => ({ + onError: setEnrollmentError, + errorMessage: intl.formatMessage(messages.statusMessageEnrollmentError), + }), [intl]); + + const enrollAndRedirect = useEnrollment(enrollmentConfig); + + const handleChangeEnrollment = async () => { + setIsEnrollmentPending(true); + try { + await enrollAndRedirect(courseId, `${getConfig().LMS_BASE_URL}/dashboard`); + } catch (error) { + setIsEnrollmentPending(false); + logError('Failed to enroll in course', error); + } + }; + + const handleEcommerceCheckout = () => { + if (!ecommerceCheckoutLink) { + logError('Ecommerce checkout link is not available'); + return; + } + window.location.assign(ecommerceCheckoutLink); + }; + + return { + enrollmentError, + isEnrollmentPending, + handleChangeEnrollment, + handleEcommerceCheckout, + }; +}; diff --git a/src/course-about/course-intro/hooks/useEnrollmentStatus.tsx b/src/course-about/course-intro/hooks/useEnrollmentStatus.tsx new file mode 100644 index 00000000..25b6d7d3 --- /dev/null +++ b/src/course-about/course-intro/hooks/useEnrollmentStatus.tsx @@ -0,0 +1,74 @@ +import { Button } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import CourseAboutEnrollmentButtonSlot from '@src/plugin-slots/CourseAboutEnrollmentButtonSlot'; +import { StatusMessage, EnrolledStatus } from '../components'; +import { getLearningHomePageUrl } from '../utils'; +import messages from '../messages'; +import { STATUS_MESSAGE_VARIANTS } from '../constants'; +import type { UseEnrollmentStatusTypes } from './types'; + +export const useEnrollmentStatus = ({ + courseAboutData, + enrollmentError, + authenticatedUser, + isEnrollmentPending, + handleChangeEnrollment, + handleEcommerceCheckout, +}: UseEnrollmentStatusTypes) => { + const intl = useIntl(); + const { + id: courseId, + canEnroll, + enrollment, + isShibCourse, + isCourseFull, + allowAnonymous, + singlePaidMode, + invitationOnly, + ecommerceCheckout, + showCoursewareLink, + } = courseAboutData; + + const renderStatusContent = () => { + if (enrollmentError) { + return ; + } + + if (authenticatedUser && enrollment.isActive) { + return ; + } + + if (isCourseFull) { + return ; + } + + if (invitationOnly && !canEnroll) { + return ; + } + + if (!isShibCourse && !canEnroll) { + return ; + } + + if (allowAnonymous && showCoursewareLink) { + return ( + + ); + } + + return ( + + ); + }; + + return { renderStatusContent }; +}; diff --git a/src/course-about/course-intro/messages.ts b/src/course-about/course-intro/messages.ts new file mode 100644 index 00000000..cd96ea55 --- /dev/null +++ b/src/course-about/course-intro/messages.ts @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + statusMessageEnrolled: { + id: 'category.course-about.course-intro.status-message.enrolled', + defaultMessage: 'You are enrolled in this course', + description: 'The text for the status message when the user is enrolled in the course.', + }, + viewCourseBtn: { + id: 'category.course-about.course-intro.view-course-btn', + defaultMessage: 'View course', + description: 'The text for the button to view the course.', + }, + statusMessageFull: { + id: 'category.course-about.course-intro.status-message.full', + defaultMessage: 'Course is full', + description: 'The text for the status message when the course is full.', + }, + statusMessageEnrollmentInvitationOnly: { + id: 'category.course-about.course-intro.status-message.enrollment-invitation-only', + defaultMessage: 'Enrollment in this course is by invitation only', + description: 'The text for the status message when the enrollment is by invitation only.', + }, + statusMessageEnrollmentClosed: { + id: 'category.course-about.course-intro.status-message.enrollment-closed', + defaultMessage: 'Enrollment is closed', + description: 'The text for the status message when the enrollment is closed.', + }, + enrollNowBtn: { + id: 'category.course-about.course-intro.enroll-now-btn', + defaultMessage: 'Enroll now', + description: 'The text for the button to enroll in the course.', + }, + enrollNowBtnPending: { + id: 'category.course-about.course-intro.enroll-now-btn-pending', + defaultMessage: 'Enrolling...', + description: 'The text for the button to enroll in the course when the enrollment is pending.', + }, + statusMessageEnrollmentError: { + id: 'category.course-about.course-intro.status-message.enrollment-error', + defaultMessage: 'An error occurred. Please try again later.', + description: 'The text for the status message when an error occurs during enrollment.', + }, +}); + +export default messages; diff --git a/src/course-about/course-intro/utils.ts b/src/course-about/course-intro/utils.ts new file mode 100644 index 00000000..fbf0385d --- /dev/null +++ b/src/course-about/course-intro/utils.ts @@ -0,0 +1,6 @@ +import { getConfig } from '@edx/frontend-platform'; + +/** + * Returns the absolute URL to the learning home page for a given course. + */ +export const getLearningHomePageUrl = (courseId: string) => `${getConfig().LEARNING_BASE_URL}/learning/course/${courseId}/home`; diff --git a/src/course-about/data/__tests__/data.test.tsx b/src/course-about/data/__tests__/data.test.tsx new file mode 100644 index 00000000..0bb48c45 --- /dev/null +++ b/src/course-about/data/__tests__/data.test.tsx @@ -0,0 +1,146 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; + +import { renderHook, waitFor } from '@src/setupTest'; +import { mockCourseAboutResponse } from '@src/__mocks__'; +import { useCourseAboutData, useEnrollment } from '../hooks'; +import { fetchCourseAboutData, changeCourseEnrolment } from '../api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('Course About Data Layer', () => { + const courseId = 'course-v1:test+123+2024'; + const redirectUrl = '/dashboard'; + const mockHttpClient = { + get: jest.fn(), + post: jest.fn(), + }; + + let originalLocation: Location; + let queryClient: QueryClient; + + beforeEach(() => { + jest.clearAllMocks(); + + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + + originalLocation = window.location; + + Object.defineProperty(window, 'location', { + value: { href: '' }, + writable: true, + }); + + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + afterEach(() => { + window.location = originalLocation; + }); + + const renderHookWithClient = (hook: () => any) => renderHook(hook, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + describe('API Functions', () => { + it('fetchCourseAboutData should fetch and transform course data', async () => { + mockHttpClient.get.mockResolvedValueOnce({ data: mockCourseAboutResponse }); + + const result = await fetchCourseAboutData(courseId); + + expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining(courseId)); + expect(result).toEqual(mockCourseAboutResponse); + }); + + it('changeCourseEnrolment should make a POST request with correct data', async () => { + const mockResponse = { data: { success: true } }; + mockHttpClient.post.mockResolvedValueOnce(mockResponse); + + const result = await changeCourseEnrolment(courseId); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + expect.any(String), + { + course_id: courseId, + enrollment_action: 'enroll', + }, + expect.objectContaining({ + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('Hooks', () => { + it('useCourseAboutData should fetch and return course data', async () => { + mockHttpClient.get.mockResolvedValueOnce({ data: mockCourseAboutResponse }); + + const { result } = renderHookWithClient(() => useCourseAboutData(courseId)); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(mockCourseAboutResponse); + }); + + it('useEnrollment should handle successful enrollment', async () => { + const onError = jest.fn(); + const errorMessage = 'Enrollment failed'; + + mockHttpClient.post.mockResolvedValueOnce({ data: { success: true } }); + const { result } = renderHookWithClient(() => useEnrollment({ onError, errorMessage })); + + await result.current(courseId, redirectUrl); + + expect(mockHttpClient.post).toHaveBeenCalled(); + expect(window.location.href).toBe(redirectUrl); + }); + + it('useEnrollment should handle 403 error and redirect to login', async () => { + const onError = jest.fn(); + const errorMessage = 'Enrollment failed'; + + mockHttpClient.post.mockRejectedValueOnce({ + customAttributes: { httpErrorStatus: 403 }, + }); + + const { result } = renderHookWithClient(() => useEnrollment({ onError, errorMessage })); + + await result.current(courseId, redirectUrl); + + const expectedLoginUrl = `${getConfig().LOGIN_URL}?next=${encodeURIComponent(`/courses/${courseId}/about`)}`; + expect(window.location.href).toBe(expectedLoginUrl); + expect(onError).not.toHaveBeenCalled(); + }); + + it('useEnrollment should handle other errors', async () => { + const onError = jest.fn(); + const errorMessage = 'Enrollment failed'; + + mockHttpClient.post.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHookWithClient(() => useEnrollment({ onError, errorMessage })); + + await result.current(courseId, redirectUrl); + + expect(onError).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/src/course-about/data/api.ts b/src/course-about/data/api.ts new file mode 100644 index 00000000..bb84a4d9 --- /dev/null +++ b/src/course-about/data/api.ts @@ -0,0 +1,33 @@ +import { camelCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { getChangeEnrollmentUrl, getCourseAboutDataUrl } from './urls'; + +/** + * Fetches course about data from the API. + * @async + */ +export const fetchCourseAboutData = async (courseId: string) => { + const { data } = await getAuthenticatedHttpClient().get(getCourseAboutDataUrl(courseId)); + return camelCaseObject(data); +}; + +/** + * Changes the enrollment status for a course. + * @async + */ +export const changeCourseEnrolment = async (courseId: string) => { + const { data } = await getAuthenticatedHttpClient().post( + getChangeEnrollmentUrl(), + { + course_id: courseId, + enrollment_action: 'enroll', + }, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + }, + ); + return camelCaseObject(data); +}; diff --git a/src/course-about/data/hooks.ts b/src/course-about/data/hooks.ts new file mode 100644 index 00000000..43ec61e2 --- /dev/null +++ b/src/course-about/data/hooks.ts @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; + +import { fetchCourseAboutData, changeCourseEnrolment } from './api'; +import type { EnrollmentFunctionTypes, UseEnrollmentParamsTypes, HttpError } from './types'; + +/** + * A React Query hook that fetches course about data. + */ +export const useCourseAboutData = (courseId: string) => useQuery({ + queryKey: ['courseAboutData', courseId], + queryFn: () => fetchCourseAboutData(courseId), +}); + +/** + * Custom hook for handling course enrollment and redirection. + */ +export function useEnrollment({ onError, errorMessage }: UseEnrollmentParamsTypes): EnrollmentFunctionTypes { + return useCallback(async (courseId, redirectUrl) => { + try { + await changeCourseEnrolment(courseId); + window.location.href = redirectUrl; + } catch (error) { + if ((error as HttpError)?.customAttributes?.httpErrorStatus === 403) { + const nextPath = `/courses/${courseId}/about`; + window.location.href = `${getConfig().LOGIN_URL}?next=${encodeURIComponent(nextPath)}`; + return; + } + onError(errorMessage); + } + }, [onError, errorMessage]); +} diff --git a/src/course-about/data/types.ts b/src/course-about/data/types.ts new file mode 100644 index 00000000..79d1c0e3 --- /dev/null +++ b/src/course-about/data/types.ts @@ -0,0 +1,14 @@ +export interface UseEnrollmentParamsTypes { + onError: (msg: string) => void; + errorMessage: string; +} + +export interface EnrollmentFunctionTypes { + (courseId: string, redirectUrl: string): Promise; +} + +export interface HttpError { + customAttributes?: { + httpErrorStatus?: number; + }; +} diff --git a/src/course-about/data/urls.ts b/src/course-about/data/urls.ts new file mode 100644 index 00000000..0dbf660c --- /dev/null +++ b/src/course-about/data/urls.ts @@ -0,0 +1,16 @@ +import { getConfig } from '@edx/frontend-platform'; + +/** + * Generates the full API URL for fetching a course's "About" page data. + * @param {string} courseId The identifier of the course (e.g., 'course-v1:Org+Course+Run'). + * @returns {string} The full API URL to fetch the course's about data. + */ +export const getCourseAboutDataUrl = ( + courseId: string, +) => `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`; + +/** + * Generates the full URL for the page where a user can change their enrollment status. + * @returns {string} The full URL for the change enrollment page. + */ +export const getChangeEnrollmentUrl = () => `${getConfig().LMS_BASE_URL}/change_enrollment`; diff --git a/src/course-about/messages.ts b/src/course-about/messages.ts new file mode 100644 index 00000000..aff0839a --- /dev/null +++ b/src/course-about/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + errorMessage: { + id: 'category.catalog.error-page-message', + defaultMessage: 'If you experience repeated failures, please email support at {supportEmail}', + description: 'Error page message.', + }, +}); + +export default messages; diff --git a/src/course-about/types.ts b/src/course-about/types.ts new file mode 100644 index 00000000..e49e95d9 --- /dev/null +++ b/src/course-about/types.ts @@ -0,0 +1,157 @@ +export interface CourseGoals { + selectedGoal: string | null; + weeklyLearningGoalEnabled: boolean; +} + +export interface Enrollment { + mode: string | null; + isActive: boolean; +} + +export interface EntranceExamData { + entranceExamCurrentScore: number; + entranceExamEnabled: boolean; + entranceExamId: string; + entranceExamMinimumScorePct: number; + entranceExamPassed: boolean; +} + +export interface CourseImage { + uri: string | null; +} + +export interface CourseVideo { + uri: string | null; +} + +export interface CourseImageUrls { + raw: string; + small: string; + large: string; +} + +export interface CourseMedia { + courseImage: CourseImage; + courseVideo: CourseVideo; + image: CourseImageUrls; +} + +export interface CertificateData { + certStatus: string; + certWebViewUrl: string | null; + downloadUrl: string | null; + certificateAvailableDate: string | null; +} + +export interface Celebrations { + firstSection: boolean; + streakLengthToCelebrate: number | null; + streakDiscountEnabled: boolean; + weeklyGoal: boolean; +} + +export interface Notes { + enabled: boolean; + visible: boolean; +} + +export interface SinglePaidMode { + [key: string]: any; +} + +export interface PrerequisiteCourse { + key: string; + name: string; + shortDescription?: string; +} + +export interface OCWLink { + title: string; + url: string; + description?: string; +} + +export interface CourseAboutData { + accessExpiration: string | null; + contentTypeGatingEnabled: boolean; + courseGoals: CourseGoals; + effort: string | null; + end: string | null; + enrollment: Enrollment; + enrollmentStart: string | null; + enrollmentEnd: string | null; + entranceExamData: EntranceExamData; + id: string; + license: string | null; + language: string; + media: CourseMedia; + name: string; + offer: any | null; + relatedPrograms: any[] | null; + shortDescription: string; + start: string; + startDisplay: string | null; + startType: string; + pacing: string; + userTimezone: string | null; + showCalculator: boolean; + canAccessProctoredExams: boolean; + notes: Notes; + marketingUrl: string | null; + celebrations: Celebrations; + userHasPassingGrade: boolean; + courseExitPageIsActive: boolean; + certificateData: CertificateData; + verifyIdentityUrl: string | null; + verificationStatus: string; + linkedinAddToProfileUrl: string | null; + isIntegritySignatureEnabled: boolean; + userNeedsIntegritySignature: boolean; + learningAssistantEnabled: boolean; + showCoursewareLink: boolean; + isCourseFull: boolean; + canEnroll: boolean; + invitationOnly: boolean; + isShibCourse: boolean; + allowAnonymous: boolean; + ecommerceCheckout: boolean; + singlePaidMode: SinglePaidMode; + ecommerceCheckoutLink: string | null; + courseImageUrls: string[]; + startDateIsStillDefault: boolean; + advertisedStart: string | null; + coursePrice: string; + preRequisiteCourses: PrerequisiteCourse[]; + aboutSidebarHtml: string | null; + displayNumberWithDefault: string; + displayOrgWithDefault: string; + overview: string; + ocwLinks: OCWLink[]; + prerequisites: string[]; +} + +export type CourseAboutDataPartial = Omit, 'enrollment'> & { + enrollment: EnrollmentPartial; +}; + +export type CourseMediaPartial = { + courseImage: CourseImage; + courseVideo?: CourseVideo; +}; + +export type EnrollmentPartial = Pick; diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx index d4778b1e..db3a2e73 100644 --- a/src/generic/course-card/index.tsx +++ b/src/generic/course-card/index.tsx @@ -6,12 +6,10 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import noCourseImg from '@src/assets/images/no-course-image.svg'; -import { CourseCardProps } from './types'; +import type { CourseCardProps } from './types'; import messages from './messages'; import { getFullImageUrl, getStartDateDisplay } from './utils'; -// TODO: Determine the final design for the course Card component. -// Issue: https://github.com/openedx/frontend-app-catalog/issues/10 export const CourseCard = ({ original: courseData, isLoading }: CourseCardProps) => { const intl = useIntl(); const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth }); diff --git a/src/home/components/home-banner/index.scss b/src/home/components/home-banner/index.scss index 95234e28..490bbe00 100644 --- a/src/home/components/home-banner/index.scss +++ b/src/home/components/home-banner/index.scss @@ -7,7 +7,7 @@ z-index: -1; // To generate a CSS variable with an image, use an absolute link (e.g. https://myfavoritecdn.url/home-bg.jpg) // Paragon issue: https://github.com/openedx/paragon/issues/3707 - background-image: var(--pgn-catalog-home-page-banner-background-image, none); + background-image: var(--catalog-home-page-banner-background-image, none); background-repeat: no-repeat; background-position: center; background-size: cover; diff --git a/src/index.scss b/src/index.scss index 4608d352..aacae09d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -2,5 +2,6 @@ @import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-footer/dist/footer"; -@import "assets/scss/animations"; -@import "home"; +@import "./assets/scss/animations"; +@import "./home"; +@import "./course-about/CourseAboutPage"; diff --git a/src/plugin-slots/CourseAboutCourseImageSlot/README.md b/src/plugin-slots/CourseAboutCourseImageSlot/README.md new file mode 100644 index 00000000..3abb6dab --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseImageSlot/README.md @@ -0,0 +1,45 @@ +# Course about page course image slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.course_image` + +## Description + +This slot is used to replace/modify/hide the entire Course about page course image. + +## Examples + +### Default content + +![Course about page course image slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course about page course image slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course about page course image entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.course_image': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_course_image_component', + type: DIRECT_PLUGIN, + RenderWidget: () => ( +

🦶

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_custom.png new file mode 100644 index 00000000..c16af935 Binary files /dev/null and b/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_default.png new file mode 100644 index 00000000..ba6bb54d Binary files /dev/null and b/src/plugin-slots/CourseAboutCourseImageSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutCourseImageSlot/index.tsx b/src/plugin-slots/CourseAboutCourseImageSlot/index.tsx new file mode 100644 index 00000000..cd8de07b --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseImageSlot/index.tsx @@ -0,0 +1,27 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { Image } from '@openedx/paragon'; + +import noCourseImg from '@src/assets/images/no-course-image.svg'; +import type { CourseAboutCourseImageSlotProps } from './types'; + +const CourseAboutCourseImageSlot = ({ imgSrc, altText }: CourseAboutCourseImageSlotProps) => ( + + {altText}) => { + e.currentTarget.src = noCourseImg; + }} + /> + +); + +export default CourseAboutCourseImageSlot; diff --git a/src/plugin-slots/CourseAboutCourseImageSlot/types.ts b/src/plugin-slots/CourseAboutCourseImageSlot/types.ts new file mode 100644 index 00000000..199f77c7 --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseImageSlot/types.ts @@ -0,0 +1,4 @@ +export type CourseAboutCourseImageSlotProps = { + imgSrc: string; + altText: string; +}; diff --git a/src/plugin-slots/CourseAboutCourseMediaSlot/README.md b/src/plugin-slots/CourseAboutCourseMediaSlot/README.md new file mode 100644 index 00000000..97afadf1 --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseMediaSlot/README.md @@ -0,0 +1,45 @@ +# Course about page course media slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.course_media` + +## Description + +This slot is used to replace/modify/hide the entire Course about page course media section. + +## Examples + +### Default content + +![Course about page course media slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course about page course media slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course about page course media section entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.course_media': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_course_media_component', + type: DIRECT_PLUGIN, + RenderWidget: () => ( +

🦶

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_custom.png new file mode 100644 index 00000000..c16af935 Binary files /dev/null and b/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_default.png new file mode 100644 index 00000000..ba6bb54d Binary files /dev/null and b/src/plugin-slots/CourseAboutCourseMediaSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutCourseMediaSlot/index.tsx b/src/plugin-slots/CourseAboutCourseMediaSlot/index.tsx new file mode 100644 index 00000000..e8286858 --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseMediaSlot/index.tsx @@ -0,0 +1,18 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +import CourseMedia from '@src/course-about/course-intro/course-media/CourseMedia'; +import type { CourseAboutCourseMediaSlotProps } from './types'; + +const CourseAboutCourseMediaSlot = ({ courseAboutData }: CourseAboutCourseMediaSlotProps) => ( + + + +); + +export default CourseAboutCourseMediaSlot; diff --git a/src/plugin-slots/CourseAboutCourseMediaSlot/types.ts b/src/plugin-slots/CourseAboutCourseMediaSlot/types.ts new file mode 100644 index 00000000..6117c322 --- /dev/null +++ b/src/plugin-slots/CourseAboutCourseMediaSlot/types.ts @@ -0,0 +1,5 @@ +import type { CourseMediaTypes } from '@src/course-about/course-intro/course-media/types'; + +export type CourseAboutCourseMediaSlotProps = { + courseAboutData: CourseMediaTypes['courseAboutData']; +}; diff --git a/src/plugin-slots/CourseAboutEnrollmentButtonSlot/README.md b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/README.md new file mode 100644 index 00000000..9d4582fe --- /dev/null +++ b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/README.md @@ -0,0 +1,69 @@ +# Course enrollment button slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.enrollment_button` + +## Description + +This slot is used to replace/modify/hide the entire course enrollment button on the Course about page. + +## Examples + +### Default content + +![Course enrollment button slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![Course enrollment button slot with a simple custom button](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the course enrollment button entirely (in this case with custom button component) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Button } from '@openedx/paragon'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.enrollment_button': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_enrollment_button_component', + type: DIRECT_PLUGIN, + RenderWidget: ({ + singlePaidMode, + ecommerceCheckout, + isEnrollmentPending, + onEnroll, + onEcommerceCheckout, + }) => { + const handleClick = () => { + if (singlePaidMode && ecommerceCheckout) { + onEcommerceCheckout(); + } else { + onEnroll(); + } + }; + + return ( + + ); + }, + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_custom.png new file mode 100644 index 00000000..00e95f61 Binary files /dev/null and b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_default.png new file mode 100644 index 00000000..313c40b2 Binary files /dev/null and b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutEnrollmentButtonSlot/index.tsx b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/index.tsx new file mode 100644 index 00000000..9be0df8f --- /dev/null +++ b/src/plugin-slots/CourseAboutEnrollmentButtonSlot/index.tsx @@ -0,0 +1,36 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +import { EnrollmentButton } from '@src/course-about/course-intro/components'; +import type { EnrollmentButtonTypes } from '@src/course-about/course-intro/components/types'; + +const CourseAboutEnrollmentButtonSlot = ({ + singlePaidMode, + ecommerceCheckout, + isEnrollmentPending, + onEnroll, + onEcommerceCheckout, +}: EnrollmentButtonTypes) => ( + + + +); + +export default CourseAboutEnrollmentButtonSlot; diff --git a/src/plugin-slots/CourseAboutIntroSlot/README.md b/src/plugin-slots/CourseAboutIntroSlot/README.md new file mode 100644 index 00000000..1f81133c --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroSlot/README.md @@ -0,0 +1,45 @@ +# Course about page intro slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.intro` + +## Description + +This slot is used to replace/modify/hide the entire Course about page intro section. + +## Examples + +### Default content + +![Course about page intro section slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course about page intro section slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course about page intro section entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.intro': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_intro_component', + type: DIRECT_PLUGIN, + RenderWidget: () => ( +

🦶

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_custom.png new file mode 100644 index 00000000..4758ce0d Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_default.png new file mode 100644 index 00000000..4d5ac32f Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutIntroSlot/index.tsx b/src/plugin-slots/CourseAboutIntroSlot/index.tsx new file mode 100644 index 00000000..06956205 --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroSlot/index.tsx @@ -0,0 +1,17 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import type { CourseAboutDataPartial } from '@src/course-about/types'; +import { CourseIntro } from '@src/course-about/course-intro/CourseIntro'; + +const CourseAboutIntroSlot = ({ courseAboutData }: { courseAboutData: CourseAboutDataPartial }) => ( + + + +); + +export default CourseAboutIntroSlot; diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/README.md b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/README.md new file mode 100644 index 00000000..ae37682d --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/README.md @@ -0,0 +1,56 @@ +# Course about page video button slot + +### Slot ID: `org.openedx.frontend.catalog.home_page.promo_video_button` + +## Description + +This slot is used to replace/modify/hide the entire Course about page video button. + +## Examples + +### Default content + +![Course about page video button slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course about page video button slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course about page video button entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Button, Image } from '@openedx/paragon'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.intro_video_button': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_intro_video_button_component', + type: DIRECT_PLUGIN, + RenderWidget: ({ courseImageSrc, courseImageAltText, openVideoModal }) => ( + <> + {courseImageAltText} + + + ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_custom.png new file mode 100644 index 00000000..de1ca9ea Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_default.png new file mode 100644 index 00000000..44e3653f Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/index.tsx b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/index.tsx new file mode 100644 index 00000000..75c02daa --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/index.tsx @@ -0,0 +1,41 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { Button, Icon } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { PlayCircleFilledWhite as PlayCircleFilledWhiteIcon } from '@openedx/paragon/icons'; + +import messages from '@src/course-about/course-intro/course-media/messages'; +import CourseAboutCourseImageSlot from '@src/plugin-slots/CourseAboutCourseImageSlot'; +import type { CourseAboutIntroVideoButtonSlotProps } from './types'; + +export const CourseAboutIntroVideoButtonSlot = ({ + courseImageSrc, courseImageAltText, openVideoModal, +}: CourseAboutIntroVideoButtonSlotProps) => { + const intl = useIntl(); + + return ( + + + + ); +}; diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/types.ts b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/types.ts new file mode 100644 index 00000000..3be246d3 --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/types.ts @@ -0,0 +1,5 @@ +export interface CourseAboutIntroVideoButtonSlotProps { + courseImageSrc: string; + courseImageAltText: string; + openVideoModal: () => void; +} diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/README.md b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/README.md new file mode 100644 index 00000000..3015d80d --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/README.md @@ -0,0 +1,45 @@ +# Course about page intro video modal content slot + +### Slot ID: `org.openedx.frontend.catalog.course_about_page.intro_video_modal_content` + +## Description + +This slot is used to replace/modify/hide the entire Course about page intro video modal content. + +## Examples + +### Default content + +![Course about page intro video modal content slot with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Course about page intro video modal content slot](./images/screenshot_custom.png) + +The following `env.config.tsx` will replace the Course about page intro video modal content entirely (in this case with a centered `h1` tag) + +```tsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.catalog.course_about_page.intro_video_modal_content': { + keepDefault: false, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_course_about_page_intro_video_modal_content_component', + type: DIRECT_PLUGIN, + RenderWidget: () => ( +

🦶

+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_custom.png new file mode 100644 index 00000000..fc8c1c09 Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_custom.png differ diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_default.png new file mode 100644 index 00000000..03636474 Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/images/screenshot_default.png differ diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/index.tsx b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/index.tsx new file mode 100644 index 00000000..d0a7b7d2 --- /dev/null +++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/index.tsx @@ -0,0 +1,32 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +import { DEFAULT_VIDEO_MODAL_HEIGHT, DEFAULT_VIDEO_MODAL_WIDTH, IFRAME_FEATURE_POLICY } from '@src/constants'; +import messages from '@src/generic/video-modal/messages'; +import type { CourseAboutIntroVideoModalContentSlotProps } from './types'; + +export const CourseAboutIntroVideoModalContentSlot = ({ + videoId, + width = DEFAULT_VIDEO_MODAL_WIDTH, + height = DEFAULT_VIDEO_MODAL_HEIGHT, +}: CourseAboutIntroVideoModalContentSlotProps) => { + const intl = useIntl(); + + return ( + +