From 16727ab920eb9c4e780e723969e6f49cf410063b Mon Sep 17 00:00:00 2001 From: sundarthapa2u Date: Tue, 4 Nov 2025 16:15:45 +0000 Subject: [PATCH] feat: adding accesslock plugin to MFE --- .../course/sequence/AccessLock/AccessLock.jsx | 59 ++++++++++++++++ .../sequence/AccessLock/AccessLock.test.jsx | 34 +++++++++ .../course/sequence/AccessLock/index.js | 1 + .../course/sequence/AccessLock/messages.ts | 46 ++++++++++++ .../course/sequence/Unit/UnitSuspense.jsx | 5 +- src/courseware/data/utils.js | 1 + .../AccessLockContentMessageSlot/README.md | 70 +++++++++++++++++++ .../AccessLockContentMessageSlot/index.tsx | 23 ++++++ src/plugin-slots/README.md | 1 + 9 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/courseware/course/sequence/AccessLock/AccessLock.jsx create mode 100644 src/courseware/course/sequence/AccessLock/AccessLock.test.jsx create mode 100644 src/courseware/course/sequence/AccessLock/index.js create mode 100644 src/courseware/course/sequence/AccessLock/messages.ts create mode 100644 src/plugin-slots/AccessLockContentMessageSlot/README.md create mode 100644 src/plugin-slots/AccessLockContentMessageSlot/index.tsx diff --git a/src/courseware/course/sequence/AccessLock/AccessLock.jsx b/src/courseware/course/sequence/AccessLock/AccessLock.jsx new file mode 100644 index 0000000000..5eec9248f5 --- /dev/null +++ b/src/courseware/course/sequence/AccessLock/AccessLock.jsx @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import CompleteIcon from '../sequence-navigation/CompleteIcon'; +import { Button } from '@openedx/paragon'; +import messages from './messages'; + +const AccessLock = ({ + courseId +}) => { + const intl = useIntl(); + const handleClick = () => {}; + + const Check = ; + const DisplayPrice = () => { + return (<> + {intl.formatMessage(messages['learn.accessLock.upgrade.buttonText'])} + $118.15 + ($139) + ) + }; + return ( + <> +

+ + {intl.formatMessage(messages['learn.accessLock.content.locked'])} +

+

+ {intl.formatMessage(messages['learn.accessLock.upgrade'])} +

+
+
+

+ {intl.formatMessage(messages['learn.accessLock.upgrade.header'])} +

+
    +
  • {Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitOne'])}
  • +
  • {Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitTwo'])}
  • +
  • {Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitThree'])}
  • +
  • {Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitFour'])}
  • +
+
+
+

+ +

+
+
+ + ); +}; +AccessLock.propTypes = { + courseId: PropTypes.string.isRequired, +}; +export default AccessLock; diff --git a/src/courseware/course/sequence/AccessLock/AccessLock.test.jsx b/src/courseware/course/sequence/AccessLock/AccessLock.test.jsx new file mode 100644 index 0000000000..76b8e69734 --- /dev/null +++ b/src/courseware/course/sequence/AccessLock/AccessLock.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { + render, screen, fireEvent, initializeMockApp, +} from '../../../../setupTest'; +import AssignmentLock from './AccessLock'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +describe('Assignment Lock', () => { + const mockData = { + courseId: 'test-course-id', + prereqSectionName: 'test-prerequisite-section-name', + prereqId: 'test-prerequisite-id', + sequenceTitle: 'test-sequence-title', + }; + + beforeAll(async () => { + await initializeMockApp(); + }); + + it('displays sequence title along with lock icon', () => { + const { container } = render(, { wrapWithRouter: true }); + + const lockIcon = container.querySelector('svg'); + expect(lockIcon).toHaveClass('fa-lock'); + expect(lockIcon.parentElement).toHaveTextContent(mockData.sequenceTitle); + }); + +}); diff --git a/src/courseware/course/sequence/AccessLock/index.js b/src/courseware/course/sequence/AccessLock/index.js new file mode 100644 index 0000000000..0152b5ac43 --- /dev/null +++ b/src/courseware/course/sequence/AccessLock/index.js @@ -0,0 +1 @@ +export { default } from './AccessLock'; diff --git a/src/courseware/course/sequence/AccessLock/messages.ts b/src/courseware/course/sequence/AccessLock/messages.ts new file mode 100644 index 0000000000..6786f8ea92 --- /dev/null +++ b/src/courseware/course/sequence/AccessLock/messages.ts @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'learn.accessLock.content.locked': { + id: 'learn.accessLock.content.locked', + defaultMessage: 'The content you are trying to access is locked', + description: 'Message shown to indicate that a piece of content is unavailable and has a prerequisite.', + }, + 'learn.accessLock.upgrade': { + id: 'learn.accessLock.upgrade', + defaultMessage: "Upgrade to gain access to locked features like this one and get the most out of your course.", + description: '', + }, + 'learn.accessLock.upgrade.header': { + id: 'learn.accessLock.upgrade.header', + defaultMessage: 'When you upgrade, you get:', + description: '', + }, + 'learn.accessLock.upgrade.benefitOne': { + id: 'learn.accessLock.upgrade.benefitOne', + defaultMessage: 'Feedback and graded assignments', + description: '', + }, + 'learn.accessLock.upgrade.benefitTwo': { + id: 'learn.accessLock.upgrade.benefitTwo', + defaultMessage: 'Shareable certificate upon completion', + description: '', + }, + 'learn.accessLock.upgrade.benefitThree': { + id: 'learn.accessLock.upgrade.benefitThree', + defaultMessage: 'Lifetime access to course materials', + description: '', + }, + 'learn.accessLock.upgrade.benefitFour': { + id: 'learn.accessLock.upgrade.benefitFour', + defaultMessage: 'Instructor support', + description: '', + }, + 'learn.accessLock.upgrade.buttonText': { + id: 'learn.accessLock.upgrade.buttonText', + defaultMessage: 'Upgrade for ', + description: '', + }, +}); + +export default messages; diff --git a/src/courseware/course/sequence/Unit/UnitSuspense.jsx b/src/courseware/course/sequence/Unit/UnitSuspense.jsx index 63203ef7b3..a9a81adb9b 100644 --- a/src/courseware/course/sequence/Unit/UnitSuspense.jsx +++ b/src/courseware/course/sequence/Unit/UnitSuspense.jsx @@ -6,6 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useModel } from '@src/generic/model-store'; import PageLoading from '@src/generic/PageLoading'; import { GatedUnitContentMessageSlot } from '../../../../plugin-slots/GatedUnitContentMessageSlot'; +import { AccessLockContentMessageSlot } from '../../../../plugin-slots/AccessLockContentMessageSlot'; import messages from '../messages'; import HonorCode from '../honor-code'; @@ -26,9 +27,9 @@ const UnitSuspense = ({ return ( <> - {shouldDisplayContentGating && ( + {(shouldDisplayContentGating || unit.accessRestricted) && ( }> - + {unit.accessRestricted ? : } )} {shouldDisplayHonorCode && ( diff --git a/src/courseware/data/utils.js b/src/courseware/data/utils.js index eaf65c46ed..820d4acca9 100644 --- a/src/courseware/data/utils.js +++ b/src/courseware/data/utils.js @@ -144,6 +144,7 @@ export function normalizeSequenceMetadata(sequence) { contentType: unit.type, graded: unit.graded, containsContentTypeGatedContent: unit.contains_content_type_gated_content, + accessRestricted: unit?.access_restricted || false })), }; } diff --git a/src/plugin-slots/AccessLockContentMessageSlot/README.md b/src/plugin-slots/AccessLockContentMessageSlot/README.md new file mode 100644 index 0000000000..7a1bf7526a --- /dev/null +++ b/src/plugin-slots/AccessLockContentMessageSlot/README.md @@ -0,0 +1,70 @@ +# Gated Unit Content Message Slot + +### Slot ID: `org.openedx.frontend.learning.access_lock_content_message.v1` + +### Slot ID Aliases +* `access_lock_content_message_slot` + +### Props: +* `courseId` - String identifier for the current course + +## Description + +This slot is used to customize the message displayed when course content is gated or locked for learners who haven't upgraded to a verified track. It appears when a unit contains content that requires a paid enrollment (such as graded assignments) and the learner is on the audit track. + +The default implementation shows a `LockPaywall` component that displays an upgrade message with benefits of upgrading, including access to graded assignments, certificates, and full course features. + +This slot is conditionally rendered only when `contentTypeGatingEnabled` is true and the unit `containsContentTypeGatedContent`. + +## Example + +The following `env.config.jsx` will replace the default paywall message with a custom gated content component. + +![Gated unit message example](./images/gated-unit-message-example.png) + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.learning.access_lock_content_message.v1': { + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widgetId: 'default_contents', + widget: { + id: 'custom_gated_message', + type: DIRECT_PLUGIN, + RenderWidget: ({ courseId }) => ( +
+
+ +
+

Premium Content

+

This content is available to verified learners only.

+
+
+
+

+ Upgrade your enrollment for course {courseId} to access: +

+
    +
  • ✅ Graded assignments and quizzes
  • +
  • 🏆 Verified certificate upon completion
  • +
  • 💬 Full discussion forum access
  • +
  • 📱 Mobile app offline access
  • +
+ +
+ ), + }, + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/AccessLockContentMessageSlot/index.tsx b/src/plugin-slots/AccessLockContentMessageSlot/index.tsx new file mode 100644 index 0000000000..ff9c4454f5 --- /dev/null +++ b/src/plugin-slots/AccessLockContentMessageSlot/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import AccessLock from '../../courseware/course/sequence/AccessLock'; + + +export const AccessLockContentMessageSlot = ({ + courseId, +} : AccessLockContentMessageSlotProps) => ( + + + +); + +interface AccessLockContentMessageSlotProps { + courseId: string; +} diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md index be410164d2..96ae708848 100644 --- a/src/plugin-slots/README.md +++ b/src/plugin-slots/README.md @@ -11,6 +11,7 @@ * [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/) * [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/) * [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/) +* [`org.openedx.frontend.learning.access_lock_content_message.v1`](./AccessLockContentMessageSlot/) * [`org.openedx.frontend.learning.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/) * [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/) * [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)