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
+
+
+
+### Replaced with custom component
+
+
+
+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) => (
+
+ ) => {
+ 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
+
+
+
+### Replaced with custom component
+
+
+
+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
+
+
+
+### Replaced with custom component
+
+
+
+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
+
+
+
+### Replaced with custom component
+
+
+
+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
+
+
+
+### Replaced with custom component
+
+
+
+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 }) => (
+ <>
+
+
+ >
+ ),
+ },
+ },
+ ]
+ }
+ },
+}
+
+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
+
+
+
+### Replaced with custom component
+
+
+
+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 (
+
+
+
+ );
+};
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/types.ts b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/types.ts
new file mode 100644
index 00000000..b8c4fea6
--- /dev/null
+++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/types.ts
@@ -0,0 +1,5 @@
+export interface CourseAboutIntroVideoModalContentSlotProps {
+ videoId: string;
+ width?: string;
+ height?: number;
+}
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/README.md b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/README.md
new file mode 100644
index 00000000..0cda076f
--- /dev/null
+++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/README.md
@@ -0,0 +1,59 @@
+# Course about page intro video modal slot
+
+### Slot ID: `org.openedx.frontend.catalog.course_about_page.intro_video_modal`
+
+## Description
+
+This slot is used to replace/modify/hide the entire Course about page intro video modal.
+
+## Examples
+
+### Default content
+
+
+
+### Replaced with custom component
+
+
+
+The following `env.config.tsx` will replace the Course about page intro video modal entirely (in this case with a simple custom modal)
+
+```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.intro_video_modal': {
+ keepDefault: false,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_course_about_page_intro_video_modal_component',
+ type: DIRECT_PLUGIN,
+ RenderWidget: ({ isOpen, videoId, close }) => {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+ );
+ },
+ },
+ },
+ ]
+ }
+ },
+}
+
+export default config;
+```
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_custom.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_custom.png
new file mode 100644
index 00000000..1e2b3113
Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_custom.png differ
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_default.png b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_default.png
new file mode 100644
index 00000000..03636474
Binary files /dev/null and b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/images/screenshot_default.png differ
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/index.tsx b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/index.tsx
new file mode 100644
index 00000000..794a6eaa
--- /dev/null
+++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/index.tsx
@@ -0,0 +1,23 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+
+import { VideoModal } from '@src/generic';
+import { CourseAboutIntroVideoModalContentSlot } from '../CourseAboutIntroVideoModalContentSlot';
+import type { CourseAboutIntroVideoModalSlotProps } from './types';
+
+export const CourseAboutIntroVideoModalSlot = ({ isOpen, close, videoId }: CourseAboutIntroVideoModalSlotProps) => (
+
+
+
+
+
+);
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/types.ts b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/types.ts
new file mode 100644
index 00000000..f55b94fa
--- /dev/null
+++ b/src/plugin-slots/CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/types.ts
@@ -0,0 +1,5 @@
+export interface CourseAboutIntroVideoModalSlotProps {
+ isOpen: boolean;
+ close: () => void;
+ videoId: string;
+}
diff --git a/src/plugin-slots/CourseAboutIntroVideoSlots/index.tsx b/src/plugin-slots/CourseAboutIntroVideoSlots/index.tsx
new file mode 100644
index 00000000..d395205d
--- /dev/null
+++ b/src/plugin-slots/CourseAboutIntroVideoSlots/index.tsx
@@ -0,0 +1,9 @@
+import { CourseAboutIntroVideoButtonSlot } from './CourseAboutIntroVideoButtonSlot';
+import { CourseAboutIntroVideoModalSlot } from './CourseAboutIntroVideoModalSlot';
+import { CourseAboutIntroVideoModalContentSlot } from './CourseAboutIntroVideoModalContentSlot';
+
+export {
+ CourseAboutIntroVideoButtonSlot,
+ CourseAboutIntroVideoModalSlot,
+ CourseAboutIntroVideoModalContentSlot,
+};
diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md
index 25d90dd3..3215b6c1 100644
--- a/src/plugin-slots/README.md
+++ b/src/plugin-slots/README.md
@@ -3,9 +3,16 @@
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
* [`org.openedx.frontend.catalog.home_page.banner`](./HomeBannerSlot/)
* [`org.openedx.frontend.catalog.home_page.overlay_html`](./HomeOverlayHtmlSlot/)
-* [`org.openedx.frontend.catalog.home_page.promo_video_button`](./HomePromoVideoButtonSlot/)
-* [`org.openedx.frontend.catalog.home_page.promo_video_modal`](./HomePromoVideoModalSlot/)
-* [`org.openedx.frontend.catalog.home_page.promo_video_modal_content`](./VideoModalContentSlot/)
+* [`org.openedx.frontend.catalog.home_page.promo_video_button`](./HomePromoVideoSlots/HomePromoVideoButtonSlot/)
+* [`org.openedx.frontend.catalog.home_page.promo_video_modal`](./CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/)
+* [`org.openedx.frontend.catalog.home_page.promo_video_modal_content`](./CourseAboutIntroVideoSlots//CourseAboutIntroVideoModalContentSlot/)
* [`org.openedx.frontend.catalog.home_page.courses_list`](./HomeCoursesListSlot/)
* [`org.openedx.frontend.catalog.home_page.course_card`](./HomeCourseCardSlot/)
* [`org.openedx.frontend.catalog.generic.loader`](./LoaderSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.enrollment_button`](./CourseAboutEnrollmentButtonSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.intro`](./CourseAboutIntroSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.intro_video_button`](./CourseAboutIntroVideoSlots/CourseAboutIntroVideoButtonSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.intro_video_modal_content`](./CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalContentSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.intro_video_modal`](./CourseAboutIntroVideoSlots/CourseAboutIntroVideoModalSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.course_image`](./CourseAboutCourseImageSlot/)
+* [`org.openedx.frontend.catalog.course_about_page.course_media`](./CourseAboutCourseMediaSlot/)
diff --git a/src/setupTest.tsx b/src/setupTest.tsx
index 9a55c02f..a3240080 100644
--- a/src/setupTest.tsx
+++ b/src/setupTest.tsx
@@ -5,7 +5,7 @@ import '@testing-library/jest-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
// eslint-disable-next-line import/no-extraneous-dependencies
import {
- render as rtlRender, renderHook, waitFor, within, screen, cleanup,
+ render as rtlRender, renderHook, waitFor, within, screen, cleanup, act,
} from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import userEvent from '@testing-library/user-event';
@@ -51,4 +51,5 @@ export {
userEvent,
cleanup,
reactRouter,
+ act,
};