richie-education 2.34.1-dev39 → 2.34.1-dev44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/js/components/CourseGlimpse/CourseGlimpseFooter.tsx +84 -13
- package/js/components/CourseGlimpse/index.spec.tsx +80 -5
- package/js/components/CourseGlimpse/index.tsx +92 -76
- package/js/components/CourseGlimpse/utils.ts +31 -1
- package/js/components/Icon/index.tsx +4 -0
- package/js/types/Course.ts +18 -0
- package/js/utils/test/factories/richie.ts +6 -1
- package/package.json +1 -1
- package/scss/colors/_theme.scss +10 -3
- package/scss/objects/_course_glimpses.scss +103 -7
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { defineMessages, useIntl } from 'react-intl';
|
|
2
|
-
|
|
1
|
+
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
|
3
2
|
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
4
3
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
5
4
|
import { CourseGlimpseCourse } from 'components/CourseGlimpse/index';
|
|
5
|
+
import { CourseOffer } from 'types/Course';
|
|
6
6
|
|
|
7
7
|
const messages = defineMessages({
|
|
8
8
|
dateIconAlt: {
|
|
@@ -10,8 +10,46 @@ const messages = defineMessages({
|
|
|
10
10
|
description: 'Course date logo alternative text for screen reader users',
|
|
11
11
|
id: 'components.CourseGlimpseFooter.dateIconAlt',
|
|
12
12
|
},
|
|
13
|
+
enrollmentOfferIconFreeAlt: {
|
|
14
|
+
defaultMessage: 'The entire course can be completed for free.',
|
|
15
|
+
description: 'Course offers free alternative text',
|
|
16
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconFreeAlt',
|
|
17
|
+
},
|
|
18
|
+
enrollmentOfferIconPartiallyFreeAlt: {
|
|
19
|
+
defaultMessage: 'More than half of the course is for free.',
|
|
20
|
+
description: 'Course offers partially free alternative text',
|
|
21
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconPartiallyFreeAlt',
|
|
22
|
+
},
|
|
23
|
+
enrollmentOfferIconPaidAlt: {
|
|
24
|
+
defaultMessage: 'Course requires a payment.',
|
|
25
|
+
description: 'Course offers paid alternative text',
|
|
26
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconPaidAlt',
|
|
27
|
+
},
|
|
28
|
+
enrollmentOfferIconSubscriptionAlt: {
|
|
29
|
+
defaultMessage: 'Course requires to be a subscriber or a paid member.',
|
|
30
|
+
description: 'Course offers subscription alternative text',
|
|
31
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconSubscriptionAlt',
|
|
32
|
+
},
|
|
33
|
+
certificateOfferIconAlt: {
|
|
34
|
+
defaultMessage: 'The course offers a certification.',
|
|
35
|
+
description: 'Course certificate offer alternative text',
|
|
36
|
+
id: 'components.CourseGlimpseFooter.certificateOfferIconAlt',
|
|
37
|
+
},
|
|
13
38
|
});
|
|
14
39
|
|
|
40
|
+
const courseOfferMessages = {
|
|
41
|
+
[CourseOffer.FREE]: messages.enrollmentOfferIconFreeAlt,
|
|
42
|
+
[CourseOffer.PARTIALLY_FREE]: messages.enrollmentOfferIconPartiallyFreeAlt,
|
|
43
|
+
[CourseOffer.PAID]: messages.enrollmentOfferIconPaidAlt,
|
|
44
|
+
[CourseOffer.SUBSCRIPTION]: messages.enrollmentOfferIconSubscriptionAlt,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type OfferIconType =
|
|
48
|
+
| IconTypeEnum.OFFER_SUBSCRIPTION
|
|
49
|
+
| IconTypeEnum.OFFER_PAID
|
|
50
|
+
| IconTypeEnum.OFFER_PARTIALLY_FREE
|
|
51
|
+
| IconTypeEnum.OFFER_FREE;
|
|
52
|
+
|
|
15
53
|
/**
|
|
16
54
|
* <CourseGlimpseFooter />.
|
|
17
55
|
* This is spun off from <CourseGlimpse /> to allow easier override through webpack.
|
|
@@ -20,19 +58,52 @@ export const CourseGlimpseFooter: React.FC<{ course: CourseGlimpseCourse } & Com
|
|
|
20
58
|
course,
|
|
21
59
|
}) => {
|
|
22
60
|
const intl = useIntl();
|
|
61
|
+
const offer = course.offer ?? CourseOffer.FREE;
|
|
62
|
+
const certificateOffer = course.certificate_offer ?? null;
|
|
63
|
+
const hasCertificateOffer = certificateOffer !== null;
|
|
64
|
+
const hasEnrollmentOffer = offer !== CourseOffer.FREE;
|
|
65
|
+
const offerIcon = `icon-offer-${offer}` as OfferIconType;
|
|
66
|
+
const offerCertificateIcon = hasCertificateOffer && IconTypeEnum.SCHOOL;
|
|
67
|
+
const offerPrice = hasEnrollmentOffer && course.price;
|
|
68
|
+
|
|
23
69
|
return (
|
|
24
70
|
<div className="course-glimpse-footer">
|
|
25
|
-
<div className="course-glimpse-footer__date">
|
|
26
|
-
<Icon
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
71
|
+
<div className="course-glimpse-footer__column course-glimpse-footer__date">
|
|
72
|
+
<Icon
|
|
73
|
+
name={IconTypeEnum.CALENDAR}
|
|
74
|
+
title={intl.formatMessage(messages.dateIconAlt)}
|
|
75
|
+
size="small"
|
|
76
|
+
/>
|
|
77
|
+
<span>
|
|
78
|
+
{course.state.text.charAt(0).toUpperCase() +
|
|
79
|
+
course.state.text.substring(1) +
|
|
80
|
+
(course.state.datetime
|
|
81
|
+
? ` ${intl.formatDate(new Date(course.state.datetime!), {
|
|
82
|
+
year: 'numeric',
|
|
83
|
+
month: 'long',
|
|
84
|
+
day: 'numeric',
|
|
85
|
+
})}`
|
|
86
|
+
: '')}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="course-glimpse-footer__column course-glimpse-footer__price">
|
|
90
|
+
{offerCertificateIcon && (
|
|
91
|
+
<Icon
|
|
92
|
+
className="offer-certificate__icon"
|
|
93
|
+
name={offerCertificateIcon}
|
|
94
|
+
title={intl.formatMessage(messages.certificateOfferIconAlt)}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<Icon
|
|
98
|
+
className="offer__icon"
|
|
99
|
+
name={offerIcon}
|
|
100
|
+
title={intl.formatMessage(courseOfferMessages[offer])}
|
|
101
|
+
/>
|
|
102
|
+
{offerPrice && (
|
|
103
|
+
<span className="offer__price">
|
|
104
|
+
<FormattedNumber value={offerPrice} currency={course.price_currency} style="currency" />
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
36
107
|
</div>
|
|
37
108
|
</div>
|
|
38
109
|
);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
2
|
import { IntlProvider } from 'react-intl';
|
|
3
3
|
import { MemoryRouter } from 'react-router';
|
|
4
4
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
5
5
|
import { RichieContextFactory } from 'utils/test/factories/richie';
|
|
6
6
|
import { CourseStateTextEnum } from 'types';
|
|
7
|
+
import { CourseCertificateOffer, CourseOffer } from 'types/Course';
|
|
7
8
|
import { CourseGlimpse, CourseGlimpseCourse } from '.';
|
|
8
9
|
|
|
9
10
|
const renderCourseGlimpse = ({
|
|
@@ -53,6 +54,11 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
53
54
|
text: CourseStateTextEnum.STARTING_ON,
|
|
54
55
|
},
|
|
55
56
|
title: 'Course 42',
|
|
57
|
+
offer: CourseOffer.PAID,
|
|
58
|
+
price: 42.0,
|
|
59
|
+
certificate_offer: CourseCertificateOffer.FREE,
|
|
60
|
+
certificate_price: null,
|
|
61
|
+
price_currency: 'EUR',
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
const contextProps: CommonDataProps['context'] = RichieContextFactory().one();
|
|
@@ -63,6 +69,11 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
63
69
|
// first text we encounter should be the title, so that screen reader users get it first
|
|
64
70
|
expect(container.textContent?.indexOf('Course 42')).toBe(0);
|
|
65
71
|
|
|
72
|
+
// The course glimpse container should have the a variant class according to their offers
|
|
73
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
74
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-paid');
|
|
75
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-certificate');
|
|
76
|
+
|
|
66
77
|
// The link that wraps the course glimpse should have no title as its content is explicit enough
|
|
67
78
|
const link = container.querySelector('.course-glimpse__link');
|
|
68
79
|
expect(link).not.toHaveAttribute('title');
|
|
@@ -73,9 +84,9 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
73
84
|
screen.getByLabelText('Organization');
|
|
74
85
|
screen.getByText('Some Organization');
|
|
75
86
|
screen.getByText('Category');
|
|
76
|
-
// Matches on 'Starting on
|
|
87
|
+
// Matches on 'Starting on March 14, 2019', date is wrapped with intl <span>
|
|
77
88
|
screen.getByLabelText('Course date');
|
|
78
|
-
screen.getByText('Starting on
|
|
89
|
+
screen.getByText('Starting on March 14, 2019');
|
|
79
90
|
|
|
80
91
|
// Check course logo
|
|
81
92
|
const courseGlipseMedia = container.getElementsByClassName('course-glimpse__media');
|
|
@@ -97,6 +108,17 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
97
108
|
// The logo is rendered along with alt text "" as it is decorative and included in a link block
|
|
98
109
|
expect(orgImg).toHaveAttribute('alt', '');
|
|
99
110
|
expect(orgImg).toHaveAttribute('src', '/thumbs/org_small.png');
|
|
111
|
+
|
|
112
|
+
// Check certificate offer
|
|
113
|
+
within(container).getByRole('img', { name: 'The course offers a certification.' });
|
|
114
|
+
|
|
115
|
+
// Check offer information
|
|
116
|
+
const offerIcon = within(container).getByRole('img', { name: 'Course requires a payment.' });
|
|
117
|
+
const useElement = offerIcon.lastChild;
|
|
118
|
+
expect(useElement).toHaveAttribute('href', '#icon-offer-paid');
|
|
119
|
+
|
|
120
|
+
const offerPrice = offerIcon.nextSibling;
|
|
121
|
+
expect(offerPrice).toHaveTextContent('€42.00');
|
|
100
122
|
});
|
|
101
123
|
|
|
102
124
|
it('works when there is no call to action or datetime on the state (eg. an archived course)', () => {
|
|
@@ -115,8 +137,8 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
115
137
|
// Make sure the component renders and shows the state
|
|
116
138
|
screen.getByRole('heading', { name: 'Course 42', level: 3 });
|
|
117
139
|
const dateFormatter = Intl.DateTimeFormat('en', {
|
|
118
|
-
day: '
|
|
119
|
-
month: '
|
|
140
|
+
day: 'numeric',
|
|
141
|
+
month: 'long',
|
|
120
142
|
year: 'numeric',
|
|
121
143
|
});
|
|
122
144
|
const formatedDatetime = dateFormatter.format(new Date(course.state.datetime!));
|
|
@@ -145,4 +167,57 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
145
167
|
'course-glimpse__metadata--code',
|
|
146
168
|
);
|
|
147
169
|
});
|
|
170
|
+
|
|
171
|
+
it('does not show certificate offer if the course does not offer a certificate', () => {
|
|
172
|
+
const { container } = renderCourseGlimpse({
|
|
173
|
+
contextProps,
|
|
174
|
+
course: { ...course, certificate_offer: null },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
178
|
+
expect(containerElement).not.toHaveClass('course-glimpse--offer-certificate');
|
|
179
|
+
|
|
180
|
+
const certificicateOfferIcon = within(container).queryByRole('img', {
|
|
181
|
+
name: 'The course offers a certification.',
|
|
182
|
+
});
|
|
183
|
+
expect(certificicateOfferIcon).not.toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does show free course offer if the course has no offer', () => {
|
|
187
|
+
const { container } = renderCourseGlimpse({
|
|
188
|
+
contextProps,
|
|
189
|
+
course: { ...course, offer: null },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
193
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-free');
|
|
194
|
+
|
|
195
|
+
const offerIcon = within(container).getByRole('img', {
|
|
196
|
+
name: 'The entire course can be completed for free.',
|
|
197
|
+
});
|
|
198
|
+
const useElement = offerIcon.lastChild;
|
|
199
|
+
expect(useElement).toHaveAttribute('href', '#icon-offer-free');
|
|
200
|
+
|
|
201
|
+
// And no price is shown
|
|
202
|
+
expect(offerIcon.nextSibling).not.toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each([
|
|
206
|
+
[CourseOffer.FREE, 'The entire course can be completed for free.'],
|
|
207
|
+
[CourseOffer.PARTIALLY_FREE, 'More than half of the course is for free.'],
|
|
208
|
+
[CourseOffer.PAID, 'Course requires a payment.'],
|
|
209
|
+
[CourseOffer.SUBSCRIPTION, 'Course requires to be a subscriber or a paid member.'],
|
|
210
|
+
])('does show a specific course offer icon', (offer, altText) => {
|
|
211
|
+
const { container } = renderCourseGlimpse({
|
|
212
|
+
contextProps,
|
|
213
|
+
course: { ...course, offer },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
217
|
+
expect(containerElement).toHaveClass(`course-glimpse--offer-${offer}`);
|
|
218
|
+
|
|
219
|
+
const offerIcon = within(container).getByRole('img', { name: altText });
|
|
220
|
+
const useElement = offerIcon.lastChild;
|
|
221
|
+
expect(useElement).toHaveAttribute('href', `#icon-offer-${offer}`);
|
|
222
|
+
});
|
|
148
223
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React, { memo } from 'react';
|
|
2
2
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
|
+
import c from 'classnames';
|
|
3
4
|
|
|
4
5
|
import { Nullable } from 'types/utils';
|
|
5
6
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
6
7
|
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
7
8
|
import { CourseState } from 'types';
|
|
9
|
+
import { CourseCertificateOffer, CourseOffer } from 'types/Course';
|
|
8
10
|
import { CourseGlimpseFooter } from './CourseGlimpseFooter';
|
|
9
11
|
import CourseLink from './CourseLink';
|
|
10
12
|
|
|
@@ -40,6 +42,11 @@ export interface CourseGlimpseCourse {
|
|
|
40
42
|
duration?: string;
|
|
41
43
|
effort?: string;
|
|
42
44
|
categories?: string[];
|
|
45
|
+
certificate_offer: Nullable<CourseCertificateOffer>;
|
|
46
|
+
offer: Nullable<CourseOffer>;
|
|
47
|
+
certificate_price: Nullable<number>;
|
|
48
|
+
price: Nullable<number>;
|
|
49
|
+
price_currency: string;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface CourseGlimpseProps {
|
|
@@ -71,91 +78,100 @@ const messages = defineMessages({
|
|
|
71
78
|
|
|
72
79
|
const CourseGlimpseBase = ({ context, course }: CourseGlimpseProps & CommonDataProps) => {
|
|
73
80
|
const intl = useIntl();
|
|
81
|
+
const offer = course.offer ?? CourseOffer.FREE;
|
|
82
|
+
const hasCertificateOffer = course.certificate_offer !== null;
|
|
74
83
|
return (
|
|
75
|
-
<div
|
|
76
|
-
{
|
|
84
|
+
<div
|
|
85
|
+
className={c('course-glimpse', `course-glimpse--offer-${offer}`, {
|
|
86
|
+
'course-glimpse--offer-certificate': hasCertificateOffer,
|
|
87
|
+
})}
|
|
88
|
+
data-testid="course-glimpse"
|
|
89
|
+
>
|
|
90
|
+
<div className="course-glimpse__body">
|
|
91
|
+
{/* the media link is only here for mouse users, so hide it for keyboard/screen reader users.
|
|
77
92
|
Keyboard/sr will focus the link on the title */}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<img
|
|
88
|
-
alt=""
|
|
89
|
-
sizes={course.cover_image.sizes}
|
|
90
|
-
src={course.cover_image.src}
|
|
91
|
-
srcSet={course.cover_image.srcset}
|
|
92
|
-
/>
|
|
93
|
-
) : (
|
|
94
|
-
<div className="course-glimpse__media__empty">
|
|
95
|
-
<FormattedMessage {...messages.cover} />
|
|
96
|
-
</div>
|
|
97
|
-
)}
|
|
98
|
-
</CourseLink>
|
|
99
|
-
</div>
|
|
100
|
-
<div className="course-glimpse__content">
|
|
101
|
-
<div className="course-glimpse__wrapper">
|
|
102
|
-
<h3 className="course-glimpse__title">
|
|
103
|
-
<CourseLink
|
|
104
|
-
className="course-glimpse__link"
|
|
105
|
-
href={course.course_url}
|
|
106
|
-
to={course.course_route}
|
|
107
|
-
>
|
|
108
|
-
<span className="course-glimpse__title-text">{course.title}</span>
|
|
109
|
-
</CourseLink>
|
|
110
|
-
</h3>
|
|
111
|
-
{course.organization.image ? (
|
|
112
|
-
<div className="course-glimpse__organization-logo">
|
|
113
|
-
{/* alt forced to empty string because the organization name is rendered after */}
|
|
93
|
+
<div aria-hidden="true" className="course-glimpse__media">
|
|
94
|
+
<CourseLink
|
|
95
|
+
tabIndex={-1}
|
|
96
|
+
className="course-glimpse__link"
|
|
97
|
+
href={course.course_url}
|
|
98
|
+
to={course.course_route}
|
|
99
|
+
>
|
|
100
|
+
{/* alt forced to empty string because it's a decorative image */}
|
|
101
|
+
{course.cover_image ? (
|
|
114
102
|
<img
|
|
115
103
|
alt=""
|
|
116
|
-
sizes={course.
|
|
117
|
-
src={course.
|
|
118
|
-
srcSet={course.
|
|
104
|
+
sizes={course.cover_image.sizes}
|
|
105
|
+
src={course.cover_image.src}
|
|
106
|
+
srcSet={course.cover_image.srcset}
|
|
119
107
|
/>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
size="small"
|
|
127
|
-
/>
|
|
128
|
-
<span className="title">{course.organization.title}</span>
|
|
129
|
-
</div>
|
|
130
|
-
<div className="course-glimpse__metadata course-glimpse__metadata--code">
|
|
131
|
-
<Icon
|
|
132
|
-
name={IconTypeEnum.BARCODE}
|
|
133
|
-
title={intl.formatMessage(messages.codeIconAlt)}
|
|
134
|
-
size="small"
|
|
135
|
-
/>
|
|
136
|
-
<span>{course.code || '-'}</span>
|
|
137
|
-
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="course-glimpse__media__empty">
|
|
110
|
+
<FormattedMessage {...messages.cover} />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</CourseLink>
|
|
138
114
|
</div>
|
|
139
|
-
|
|
140
|
-
<div className="course-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
115
|
+
<div className="course-glimpse__content">
|
|
116
|
+
<div className="course-glimpse__wrapper">
|
|
117
|
+
<h3 className="course-glimpse__title">
|
|
118
|
+
<CourseLink
|
|
119
|
+
className="course-glimpse__link"
|
|
120
|
+
href={course.course_url}
|
|
121
|
+
to={course.course_route}
|
|
122
|
+
>
|
|
123
|
+
<span className="course-glimpse__title-text">{course.title}</span>
|
|
124
|
+
</CourseLink>
|
|
125
|
+
</h3>
|
|
126
|
+
{course.organization.image ? (
|
|
127
|
+
<div className="course-glimpse__organization-logo">
|
|
128
|
+
{/* alt forced to empty string because the organization name is rendered after */}
|
|
129
|
+
<img
|
|
130
|
+
alt=""
|
|
131
|
+
sizes={course.organization.image.sizes}
|
|
132
|
+
src={course.organization.image.src}
|
|
133
|
+
srcSet={course.organization.image.srcset}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
<div className="course-glimpse__metadata course-glimpse__metadata--organization">
|
|
138
|
+
<Icon
|
|
139
|
+
name={IconTypeEnum.ORG}
|
|
140
|
+
title={intl.formatMessage(messages.organizationIconAlt)}
|
|
141
|
+
size="small"
|
|
149
142
|
/>
|
|
150
|
-
<span className="
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
143
|
+
<span className="title">{course.organization.title}</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="course-glimpse__metadata course-glimpse__metadata--code">
|
|
146
|
+
<Icon
|
|
147
|
+
name={IconTypeEnum.BARCODE}
|
|
148
|
+
title={intl.formatMessage(messages.codeIconAlt)}
|
|
149
|
+
size="small"
|
|
150
|
+
/>
|
|
151
|
+
<span>{course.code || '-'}</span>
|
|
152
|
+
</div>
|
|
155
153
|
</div>
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
{course.icon ? (
|
|
155
|
+
<div className="course-glimpse__icon">
|
|
156
|
+
<span className="category-badge">
|
|
157
|
+
{/* alt forced to empty string because it's a decorative image */}
|
|
158
|
+
<img
|
|
159
|
+
alt=""
|
|
160
|
+
className="category-badge__icon"
|
|
161
|
+
sizes={course.icon.sizes}
|
|
162
|
+
src={course.icon.src}
|
|
163
|
+
srcSet={course.icon.srcset}
|
|
164
|
+
/>
|
|
165
|
+
<span className="offscreen">
|
|
166
|
+
<FormattedMessage {...messages.categoryLabel} />
|
|
167
|
+
</span>
|
|
168
|
+
<span className="category-badge__title">{course.icon.title}</span>
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
158
173
|
</div>
|
|
174
|
+
<CourseGlimpseFooter context={context} course={course} />
|
|
159
175
|
</div>
|
|
160
176
|
);
|
|
161
177
|
};
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { IntlShape } from 'react-intl';
|
|
2
2
|
import { generatePath } from 'react-router';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CourseCertificateOffer,
|
|
5
|
+
CourseOffer,
|
|
6
|
+
Course as RichieCourse,
|
|
7
|
+
isRichieCourse,
|
|
8
|
+
} from 'types/Course';
|
|
4
9
|
import {
|
|
5
10
|
CourseListItem as JoanieCourse,
|
|
6
11
|
CourseProductRelationLight,
|
|
7
12
|
isCourseProductRelation,
|
|
13
|
+
ProductType,
|
|
8
14
|
} from 'types/Joanie';
|
|
9
15
|
import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherDashboardPaths';
|
|
10
16
|
import { CourseGlimpseCourse } from '.';
|
|
@@ -40,6 +46,20 @@ const getCourseGlimpsePropsFromCourseProductRelation = (
|
|
|
40
46
|
product_id: courseProductRelation.product.id,
|
|
41
47
|
course_route: courseRoute,
|
|
42
48
|
state: courseProductRelation.product.state,
|
|
49
|
+
certificate_offer:
|
|
50
|
+
courseProductRelation.product.type === ProductType.CERTIFICATE
|
|
51
|
+
? CourseCertificateOffer.PAID
|
|
52
|
+
: null,
|
|
53
|
+
offer: courseProductRelation.product.type === ProductType.CREDENTIAL ? CourseOffer.PAID : null,
|
|
54
|
+
certificate_price:
|
|
55
|
+
courseProductRelation.product.type === ProductType.CERTIFICATE
|
|
56
|
+
? courseProductRelation.product.price
|
|
57
|
+
: null,
|
|
58
|
+
price:
|
|
59
|
+
courseProductRelation.product.type === ProductType.CREDENTIAL
|
|
60
|
+
? courseProductRelation.product.price
|
|
61
|
+
: null,
|
|
62
|
+
price_currency: courseProductRelation.product.price_currency,
|
|
43
63
|
};
|
|
44
64
|
};
|
|
45
65
|
|
|
@@ -59,6 +79,11 @@ const getCourseGlimpsePropsFromRichieCourse = (course: RichieCourse): CourseGlim
|
|
|
59
79
|
effort: course.effort,
|
|
60
80
|
categories: course.categories,
|
|
61
81
|
organizations: course.organizations,
|
|
82
|
+
price: course.price,
|
|
83
|
+
price_currency: course.price_currency,
|
|
84
|
+
certificate_offer: course.certificate_offer,
|
|
85
|
+
offer: course.offer,
|
|
86
|
+
certificate_price: course.certificate_price,
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
const getCourseGlimpsePropsFromJoanieCourse = (
|
|
@@ -91,6 +116,11 @@ const getCourseGlimpsePropsFromJoanieCourse = (
|
|
|
91
116
|
},
|
|
92
117
|
state: course.state,
|
|
93
118
|
nb_course_runs: course.course_run_ids.length,
|
|
119
|
+
price: null,
|
|
120
|
+
price_currency: 'EUR',
|
|
121
|
+
certificate_offer: null,
|
|
122
|
+
offer: null,
|
|
123
|
+
certificate_price: null,
|
|
94
124
|
};
|
|
95
125
|
};
|
|
96
126
|
|
|
@@ -65,6 +65,10 @@ export enum IconTypeEnum {
|
|
|
65
65
|
UNIVERSITY = 'icon-univerity',
|
|
66
66
|
WARNING = 'icon-warning',
|
|
67
67
|
VIDEO_PLAY = 'icon-video-play',
|
|
68
|
+
OFFER_PAID = 'icon-offer-paid',
|
|
69
|
+
OFFER_FREE = 'icon-offer-free',
|
|
70
|
+
OFFER_PARTIALLY_FREE = 'icon-offer-partially_free',
|
|
71
|
+
OFFER_SUBSCRIPTION = 'icon-offer-subscription',
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export const Icon = ({ name, title, className = '', size = 'medium', ...props }: Props) => {
|
package/js/types/Course.ts
CHANGED
|
@@ -3,6 +3,19 @@ import { Resource } from 'types/Resource';
|
|
|
3
3
|
import { Nullable } from 'types/utils';
|
|
4
4
|
import { CourseListItem as JoanieCourse } from 'types/Joanie';
|
|
5
5
|
|
|
6
|
+
export enum CourseOffer {
|
|
7
|
+
PAID = 'paid',
|
|
8
|
+
FREE = 'free',
|
|
9
|
+
PARTIALLY_FREE = 'partially_free',
|
|
10
|
+
SUBSCRIPTION = 'subscription',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum CourseCertificateOffer {
|
|
14
|
+
PAID = 'paid',
|
|
15
|
+
FREE = 'free',
|
|
16
|
+
SUBSCRIPTION = 'subscription',
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
export interface Course extends Resource {
|
|
7
20
|
absolute_url: string;
|
|
8
21
|
categories: string[];
|
|
@@ -29,6 +42,11 @@ export interface Course extends Resource {
|
|
|
29
42
|
}>;
|
|
30
43
|
organizations: string[];
|
|
31
44
|
state: CourseState;
|
|
45
|
+
certificate_offer: Nullable<CourseCertificateOffer>;
|
|
46
|
+
offer: Nullable<CourseOffer>;
|
|
47
|
+
certificate_price: Nullable<number>;
|
|
48
|
+
price: Nullable<number>;
|
|
49
|
+
price_currency: string;
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
export function isRichieCourse(course: Course | JoanieCourse): course is Course {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
PacedCourse,
|
|
11
11
|
Priority,
|
|
12
12
|
} from 'types';
|
|
13
|
-
import { Course } from 'types/Course';
|
|
13
|
+
import { CourseCertificateOffer, Course, CourseOffer } from 'types/Course';
|
|
14
14
|
import { FactoryHelper } from 'utils/test/factories/helper';
|
|
15
15
|
import { factory } from './factories';
|
|
16
16
|
|
|
@@ -239,5 +239,10 @@ export const CourseLightFactory = factory<Course>(() => {
|
|
|
239
239
|
},
|
|
240
240
|
organizations: [organizationName],
|
|
241
241
|
state: CourseStateFactory().one(),
|
|
242
|
+
certificate_offer: CourseCertificateOffer.FREE,
|
|
243
|
+
offer: CourseOffer.FREE,
|
|
244
|
+
certificate_price: null,
|
|
245
|
+
price: null,
|
|
246
|
+
price_currency: 'EUR',
|
|
242
247
|
};
|
|
243
248
|
});
|
package/package.json
CHANGED
package/scss/colors/_theme.scss
CHANGED
|
@@ -217,8 +217,8 @@ $r-theme: (
|
|
|
217
217
|
),
|
|
218
218
|
course-glimpse: (
|
|
219
219
|
card-background: r-color('white'),
|
|
220
|
-
base-shadow: 0 0
|
|
221
|
-
base-hover-shadow: 0 0
|
|
220
|
+
base-shadow: 0 0 8px #00000033,
|
|
221
|
+
base-hover-shadow: 0 0 4px #00000055,
|
|
222
222
|
cta-background: r-color('battleship-grey'),
|
|
223
223
|
empty-color: r-color('slate-grey'),
|
|
224
224
|
icon-shadow: (
|
|
@@ -232,8 +232,15 @@ $r-theme: (
|
|
|
232
232
|
organization-color: r-color('firebrick6'),
|
|
233
233
|
code-color: r-color('battleship-grey'),
|
|
234
234
|
svg-icon-fill: r-color('white'),
|
|
235
|
-
footer: $battleship-grey-scheme,
|
|
236
235
|
organization-shadow: 0 0 6px r-color('light-grey'),
|
|
236
|
+
footer: $battleship-grey-scheme,
|
|
237
|
+
footer-offer-paid: $indianred3-scheme,
|
|
238
|
+
footer-offer-subscription: $indianred3-scheme,
|
|
239
|
+
footer-offer-partially_free: $indianred3-scheme,
|
|
240
|
+
footer-offer-certificate: null,
|
|
241
|
+
offer-icon-visibility: visible,
|
|
242
|
+
offer-certificate-icon-visibility: visible,
|
|
243
|
+
offer-price-visibility: visible,
|
|
237
244
|
),
|
|
238
245
|
category-badges: (
|
|
239
246
|
primary-item: $indianred3-scheme,
|
|
@@ -57,10 +57,8 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
57
57
|
|
|
58
58
|
position: relative;
|
|
59
59
|
margin: $r-course-glimpse-gutter;
|
|
60
|
-
|
|
61
|
-
box-shadow: r-theme-val(course-glimpse, base-shadow);
|
|
60
|
+
|
|
62
61
|
min-width: 16rem;
|
|
63
|
-
overflow: hidden;
|
|
64
62
|
|
|
65
63
|
@include media-breakpoint-up(sm) {
|
|
66
64
|
@include sv-flex(1, 0, calc(50% - #{$r-course-glimpse-gutter * 2}));
|
|
@@ -83,8 +81,16 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
83
81
|
pointer-events: auto;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
&__body {
|
|
85
|
+
box-shadow: r-theme-val(course-glimpse, base-shadow);
|
|
86
|
+
border-radius: $border-radius-lg;
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
transition: box-shadow 0.5s $r-ease-out;
|
|
89
|
+
z-index: 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&:hover &__body,
|
|
93
|
+
&:focus-within &__body {
|
|
88
94
|
color: inherit;
|
|
89
95
|
text-decoration: none;
|
|
90
96
|
border: 0;
|
|
@@ -149,6 +155,7 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
149
155
|
&__content {
|
|
150
156
|
font-size: 0.9rem;
|
|
151
157
|
color: r-theme-val(course-glimpse, content-color);
|
|
158
|
+
background: map-get(r-theme-val(course-glimpse, footer), 'background');
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
&__wrapper {
|
|
@@ -156,6 +163,9 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
156
163
|
display: flex;
|
|
157
164
|
flex-direction: column;
|
|
158
165
|
position: relative;
|
|
166
|
+
color: r-theme-val(course-glimpse, card-background);
|
|
167
|
+
border-radius: 0 0 $border-radius-lg $border-radius-lg;
|
|
168
|
+
background-color: r-theme-val(course-glimpse, card-background);
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
&__title,
|
|
@@ -322,9 +332,26 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
322
332
|
border-bottom-left-radius: $border-radius-lg;
|
|
323
333
|
border-bottom-right-radius: $border-radius-lg;
|
|
324
334
|
font-size: 0.7rem;
|
|
335
|
+
justify-content: space-between;
|
|
336
|
+
flex-wrap: wrap;
|
|
337
|
+
position: relative;
|
|
338
|
+
z-index: 0;
|
|
339
|
+
transition: transform 0.25s $r-ease-out;
|
|
325
340
|
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
&:after {
|
|
342
|
+
content: '';
|
|
343
|
+
position: absolute;
|
|
344
|
+
display: block;
|
|
345
|
+
top: -15px;
|
|
346
|
+
height: 30px;
|
|
347
|
+
left: 0;
|
|
348
|
+
right: 0;
|
|
349
|
+
@include r-button-colors(r-theme-val(course-glimpse, footer), $apply-border: true);
|
|
350
|
+
z-index: -1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
&__column {
|
|
354
|
+
@include sv-flex(0, 130px, auto);
|
|
328
355
|
display: flex;
|
|
329
356
|
margin: 0;
|
|
330
357
|
padding: 0.45rem 0;
|
|
@@ -335,7 +362,54 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
335
362
|
.icon {
|
|
336
363
|
margin-right: 0.5rem;
|
|
337
364
|
}
|
|
365
|
+
|
|
366
|
+
span {
|
|
367
|
+
display: inline-block;
|
|
368
|
+
max-width: 15ch;
|
|
369
|
+
font-variant-numeric: tabular-nums;
|
|
370
|
+
}
|
|
338
371
|
}
|
|
372
|
+
|
|
373
|
+
&__price {
|
|
374
|
+
.offer-certificate__icon {
|
|
375
|
+
$visibility: r-theme-val(course-glimpse, offer-certificate-icon-visibility);
|
|
376
|
+
@if $visibility == hidden {
|
|
377
|
+
display: none;
|
|
378
|
+
}
|
|
379
|
+
visibility: $visibility;
|
|
380
|
+
}
|
|
381
|
+
.offer__icon {
|
|
382
|
+
$visibility: r-theme-val(course-glimpse, offer-icon-visibility);
|
|
383
|
+
@if $visibility == hidden {
|
|
384
|
+
display: none;
|
|
385
|
+
}
|
|
386
|
+
visibility: $visibility;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.offer__icon {
|
|
390
|
+
margin-right: 0;
|
|
391
|
+
& + .offer__price {
|
|
392
|
+
margin-left: 0.25rem;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.offer__price {
|
|
397
|
+
$visibility: r-theme-val(course-glimpse, offer-price-visibility);
|
|
398
|
+
@if $visibility == hidden {
|
|
399
|
+
display: none;
|
|
400
|
+
}
|
|
401
|
+
visibility: $visibility;
|
|
402
|
+
// Align vertically the price with the icon
|
|
403
|
+
margin-top: calc(1ex - 1cap);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.course-glimpse:hover .course-glimpse-footer,
|
|
409
|
+
.course-glimpse:hover .course-glimpse__large-footer,
|
|
410
|
+
.course-glimpse:focus-within .course-glimpse-footer,
|
|
411
|
+
.course-glimpse:focus-within .course-glimpse__large-footer {
|
|
412
|
+
transform: translateY(4px);
|
|
339
413
|
}
|
|
340
414
|
|
|
341
415
|
//
|
|
@@ -346,3 +420,25 @@ $course-glimpse-content-padding-sides: 0.7rem !default;
|
|
|
346
420
|
@include sv-flex(1, 0, calc(33.33333% - #{$r-course-glimpse-gutter * 2}));
|
|
347
421
|
}
|
|
348
422
|
}
|
|
423
|
+
|
|
424
|
+
// Course Glimpse Variant according to the offer
|
|
425
|
+
$offer-schemes: (
|
|
426
|
+
certificate: r-theme-val(course-glimpse, footer-offer-certificate),
|
|
427
|
+
free: r-theme-val(course-glimpse, footer-offer-free),
|
|
428
|
+
paid: r-theme-val(course-glimpse, footer-offer-paid),
|
|
429
|
+
partially_free: r-theme-val(course-glimpse, footer-offer-partially_free),
|
|
430
|
+
subscription: r-theme-val(course-glimpse, footer-offer-subscription),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
@each $offer, $scheme in $offer-schemes {
|
|
434
|
+
@if $scheme != null {
|
|
435
|
+
.course-glimpse--offer-#{$offer} {
|
|
436
|
+
.course-glimpse-footer {
|
|
437
|
+
@include r-button-colors($scheme, $apply-border: true);
|
|
438
|
+
&:after {
|
|
439
|
+
background: map-get($scheme, 'background');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|