Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/courseware/course/sequence/AccessLock/AccessLock.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { useCallback } from 'react';

Check failure on line 1 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'useCallback' is defined but never used
Copy link
Member

Choose a reason for hiding this comment

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

This looks like a duplicate of the AccessLock in https://github.com/edx/frontend-plugin-advertisements/pull/106. Is there a reason this shouldn't be removed?

import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';

Check failure on line 3 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'useNavigate' is defined but never used
import { faCheck } from '@fortawesome/free-solid-svg-icons';

Check failure on line 4 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'/home/runner/work/frontend-app-learning/frontend-app-learning/node_modules/@fortawesome/free-solid-svg-icons/index.d.ts' imported multiple times

Check failure on line 4 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'faCheck' is defined but never used
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';

Check failure on line 6 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'/home/runner/work/frontend-app-learning/frontend-app-learning/node_modules/@fortawesome/free-solid-svg-icons/index.d.ts' imported multiple times
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';

Check failure on line 7 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'FormattedMessage' is defined but never used
import CompleteIcon from '../sequence-navigation/CompleteIcon';
import { Button } from '@openedx/paragon';

Check failure on line 9 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

`@openedx/paragon` import should occur before import of `../sequence-navigation/CompleteIcon`
import messages from './messages';

const AccessLock = ({
courseId

Check failure on line 13 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

Missing trailing comma

Check failure on line 13 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

'courseId' is defined but never used
}) => {
const intl = useIntl();
const handleClick = () => {};

const Check = <CompleteIcon size="md" className="mr-2" />;
const DisplayPrice = () => {

Check failure on line 19 in src/courseware/course/sequence/AccessLock/AccessLock.jsx

View workflow job for this annotation

GitHub Actions / tests

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “AccessLock” and pass data as props
return (<>
<span>{intl.formatMessage(messages['learn.accessLock.upgrade.buttonText'])}</span>
<span className='large-weight'>$118.15 </span>
<span style= {{ textDecoration: 'line-through' }}>($139)</span>
</>)
};
return (
<>
<h3>
<FontAwesomeIcon icon={faLock} />
{intl.formatMessage(messages['learn.accessLock.content.locked'])}
</h3>
<p>
{intl.formatMessage(messages['learn.accessLock.upgrade'])}
</p>
<div className="d-flex flex-grow-1 justify-between items-start">
<div>
<p>
{intl.formatMessage(messages['learn.accessLock.upgrade.header'])}
</p>
<ul className="list-unstyled mr-4">
<li>{Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitOne'])}</li>
<li>{Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitTwo'])}</li>
<li>{Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitThree'])}</li>
<li>{Check}{intl.formatMessage(messages['learn.accessLock.upgrade.benefitFour'])}</li>
</ul>
</div>
<div className='d-flex items-right pl-6'>
<p>
<Button variant="primary" onClick={handleClick}> <DisplayPrice /></Button>
</p>
</div>
</div>
</>
);
};
AccessLock.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default AccessLock;
34 changes: 34 additions & 0 deletions src/courseware/course/sequence/AccessLock/AccessLock.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<AssignmentLock {...mockData} />, { wrapWithRouter: true });

const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock');
expect(lockIcon.parentElement).toHaveTextContent(mockData.sequenceTitle);
});

});
1 change: 1 addition & 0 deletions src/courseware/course/sequence/AccessLock/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './AccessLock';
46 changes: 46 additions & 0 deletions src/courseware/course/sequence/AccessLock/messages.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions src/courseware/course/sequence/Unit/UnitSuspense.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,9 +27,9 @@ const UnitSuspense = ({

return (
<>
{shouldDisplayContentGating && (
{(shouldDisplayContentGating || unit.accessRestricted) && (
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />}>
<GatedUnitContentMessageSlot courseId={courseId} />
{unit.accessRestricted ? <AccessLockContentMessageSlot courseId={courseId} /> : <GatedUnitContentMessageSlot courseId={courseId} />}
Copy link
Member

Choose a reason for hiding this comment

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

Can you help me understand why you're adding a new plugin slot that, functionally, is almost identical to GatedUnitContentMessageSlot. Can't you just use the existing plugin slot to render your component?

</Suspense>
)}
{shouldDisplayHonorCode && (
Expand Down
1 change: 1 addition & 0 deletions src/courseware/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})),
};
}
Expand Down
70 changes: 70 additions & 0 deletions src/plugin-slots/AccessLockContentMessageSlot/README.md
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div className="alert alert-warning" role="alert">
<div className="d-flex align-items-center mb-3">
<i className="fa fa-lock fa-2x me-3" aria-hidden="true"></i>
<div>
<h4 className="alert-heading mb-1">Premium Content</h4>
<p className="mb-0">This content is available to verified learners only.</p>
</div>
</div>
<hr />
<p className="mb-3">
Upgrade your enrollment for course {courseId} to access:
</p>
<ul className="mb-3">
<li>✅ Graded assignments and quizzes</li>
<li>🏆 Verified certificate upon completion</li>
<li>💬 Full discussion forum access</li>
<li>📱 Mobile app offline access</li>
</ul>
<button className="btn btn-success">
Upgrade Now
</button>
</div>
),
},
},
]
}
},
}

export default config;
```
23 changes: 23 additions & 0 deletions src/plugin-slots/AccessLockContentMessageSlot/index.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<PluginSlot
id="org.openedx.frontend.learning.access_lock_content_message.v1"
idAliases={['access_lock_content_message_slot']}
pluginProps={{
courseId,
}}
>
<AccessLock courseId={courseId} />
</PluginSlot>
);

interface AccessLockContentMessageSlotProps {
courseId: string;
}
1 change: 1 addition & 0 deletions src/plugin-slots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down