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.
@@ -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 name={IconTypeEnum.CALENDAR} title={intl.formatMessage(messages.dateIconAlt)} />
27
- {course.state.text.charAt(0).toUpperCase() +
28
- course.state.text.substring(1) +
29
- (course.state.datetime
30
- ? ` ${intl.formatDate(new Date(course.state.datetime!), {
31
- year: 'numeric',
32
- month: 'short',
33
- day: 'numeric',
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 Mar 14, 2019', date is wrapped with intl <span>
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 Mar 14, 2019');
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: '2-digit',
119
- month: 'short',
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 className="course-glimpse" data-testid="course-glimpse">
76
- {/* the media link is only here for mouse users, so hide it for keyboard/screen reader users.
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
- <div aria-hidden="true" className="course-glimpse__media">
79
- <CourseLink
80
- tabIndex={-1}
81
- className="course-glimpse__link"
82
- href={course.course_url}
83
- to={course.course_route}
84
- >
85
- {/* alt forced to empty string because it's a decorative image */}
86
- {course.cover_image ? (
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.organization.image.sizes}
117
- src={course.organization.image.src}
118
- srcSet={course.organization.image.srcset}
104
+ sizes={course.cover_image.sizes}
105
+ src={course.cover_image.src}
106
+ srcSet={course.cover_image.srcset}
119
107
  />
120
- </div>
121
- ) : null}
122
- <div className="course-glimpse__metadata course-glimpse__metadata--organization">
123
- <Icon
124
- name={IconTypeEnum.ORG}
125
- title={intl.formatMessage(messages.organizationIconAlt)}
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
- {course.icon ? (
140
- <div className="course-glimpse__icon">
141
- <span className="category-badge">
142
- {/* alt forced to empty string because it's a decorative image */}
143
- <img
144
- alt=""
145
- className="category-badge__icon"
146
- sizes={course.icon.sizes}
147
- src={course.icon.src}
148
- srcSet={course.icon.srcset}
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="offscreen">
151
- <FormattedMessage {...messages.categoryLabel} />
152
- </span>
153
- <span className="category-badge__title">{course.icon.title}</span>
154
- </span>
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
- ) : null}
157
- <CourseGlimpseFooter context={context} course={course} />
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 { Course as RichieCourse, isRichieCourse } from 'types/Course';
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) => {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.34.1-dev39",
3
+ "version": "2.34.1-dev44",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -217,8 +217,8 @@ $r-theme: (
217
217
  ),
218
218
  course-glimpse: (
219
219
  card-background: r-color('white'),
220
- base-shadow: 0 0 6px r-color('light-grey'),
221
- base-hover-shadow: 0 0 6px r-color('battleship-grey'),
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
- border-radius: $border-radius-lg;
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
- &:hover,
87
- &:focus-within {
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
- &__date {
327
- @include sv-flex(1, 0, auto);
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
+ }