richie-education 3.1.3-dev8 → 3.2.1-dev1

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.
Files changed (115) hide show
  1. package/.storybook/__mocks__/utils/context.ts +4 -0
  2. package/i18n/locales/ar-SA.json +30 -10
  3. package/i18n/locales/es-ES.json +30 -10
  4. package/i18n/locales/fa-IR.json +30 -10
  5. package/i18n/locales/fr-CA.json +31 -11
  6. package/i18n/locales/fr-FR.json +32 -12
  7. package/i18n/locales/ko-KR.json +30 -10
  8. package/i18n/locales/pt-PT.json +30 -10
  9. package/i18n/locales/ru-RU.json +30 -10
  10. package/i18n/locales/vi-VN.json +30 -10
  11. package/js/api/joanie.ts +8 -8
  12. package/js/components/ContractFrame/OrganizationContractFrame.spec.tsx +11 -19
  13. package/js/components/ContractFrame/OrganizationContractFrame.tsx +4 -4
  14. package/js/components/CourseGlimpse/CourseGlimpseFooter.tsx +30 -5
  15. package/js/components/CourseGlimpse/index.spec.tsx +18 -0
  16. package/js/components/CourseGlimpse/index.stories.tsx +75 -4
  17. package/js/components/CourseGlimpse/index.tsx +4 -0
  18. package/js/components/CourseGlimpse/utils.ts +35 -30
  19. package/js/components/CourseGlimpseList/utils.ts +2 -2
  20. package/js/components/PurchaseButton/index.tsx +3 -3
  21. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +6 -3
  22. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +9 -7
  23. package/js/components/SaleTunnel/index.full-process.spec.tsx +3 -3
  24. package/js/components/SaleTunnel/index.spec.tsx +131 -64
  25. package/js/components/SaleTunnel/index.stories.tsx +17 -2
  26. package/js/components/SaleTunnel/index.tsx +2 -2
  27. package/js/components/TeacherDashboardCourseList/index.spec.tsx +3 -3
  28. package/js/components/TeacherDashboardCourseList/index.tsx +2 -2
  29. package/js/hooks/useContractArchive/index.ts +3 -3
  30. package/js/hooks/useCourseProductUnion/index.spec.tsx +16 -18
  31. package/js/hooks/useCourseProductUnion/index.ts +7 -7
  32. package/js/hooks/useCourseProducts.ts +4 -8
  33. package/js/hooks/useDefaultOrganizationId/index.tsx +4 -7
  34. package/js/hooks/useOffering/index.ts +32 -0
  35. package/js/hooks/useTeacherCoursesSearch/index.tsx +2 -2
  36. package/js/hooks/useTeacherPendingContractsCount/index.ts +4 -4
  37. package/js/pages/DashboardCourses/index.spec.tsx +14 -14
  38. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.spec.tsx +11 -14
  39. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.tsx +4 -9
  40. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.spec.tsx +11 -11
  41. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.timer.spec.tsx +10 -13
  42. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.tsx +4 -4
  43. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +20 -28
  44. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.tsx +8 -11
  45. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +6 -6
  46. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.tsx +4 -4
  47. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.spec.tsx +7 -7
  48. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.tsx +5 -5
  49. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.spec.ts +21 -28
  50. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.ts +13 -17
  51. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.spec.tsx +11 -13
  52. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +6 -6
  53. package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx +3 -3
  54. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.spec.tsx +16 -16
  55. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.tsx +4 -4
  56. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractsToSign.tsx +4 -4
  57. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.spec.tsx +21 -21
  58. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.ts +5 -10
  59. package/js/pages/TeacherDashboardCourseLearnersLayout/index.spec.tsx +61 -79
  60. package/js/pages/TeacherDashboardCourseLearnersLayout/index.tsx +1 -1
  61. package/js/pages/TeacherDashboardCoursesLoader/index.spec.tsx +11 -11
  62. package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +11 -11
  63. package/js/pages/TeacherDashboardTraining/TeacherDashboardTrainingLoader.tsx +7 -7
  64. package/js/pages/TeacherDashboardTraining/index.spec.tsx +21 -29
  65. package/js/pages/TeacherDashboardTraining/index.tsx +12 -16
  66. package/js/settings/index.ts +1 -0
  67. package/js/settings/settings.prod.ts +1 -0
  68. package/js/translations/ar-SA.json +1 -1
  69. package/js/translations/es-ES.json +1 -1
  70. package/js/translations/fa-IR.json +1 -1
  71. package/js/translations/fr-CA.json +1 -1
  72. package/js/translations/fr-FR.json +1 -1
  73. package/js/translations/ko-KR.json +1 -1
  74. package/js/translations/pt-PT.json +1 -1
  75. package/js/translations/ru-RU.json +1 -1
  76. package/js/translations/vi-VN.json +1 -1
  77. package/js/types/Course.ts +4 -0
  78. package/js/types/Joanie.ts +31 -22
  79. package/js/types/index.ts +6 -2
  80. package/js/utils/test/factories/joanie.ts +18 -11
  81. package/js/utils/test/factories/richie.ts +10 -2
  82. package/js/utils/test/mockCourseProductWithOrder.ts +4 -4
  83. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/DashboardItemEnrollment.tsx +1 -1
  84. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +1 -1
  85. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +3 -3
  86. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +1 -1
  87. package/js/widgets/Dashboard/components/DashboardItem/Order/Installment/index.tsx +4 -4
  88. package/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts +1 -1
  89. package/js/widgets/Dashboard/components/DashboardSidebar/components/ContractNavLink/index.spec.tsx +23 -28
  90. package/js/widgets/Dashboard/components/DashboardSidebar/components/ContractNavLink/index.tsx +4 -8
  91. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/index.spec.tsx +17 -24
  92. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/index.tsx +18 -21
  93. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/utils.ts +4 -4
  94. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.tsx +3 -7
  95. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +4 -4
  96. package/js/widgets/Slider/components/SlidePanel.tsx +9 -0
  97. package/js/widgets/Slider/index.stories.tsx +53 -0
  98. package/js/widgets/Slider/index.tsx +21 -2
  99. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/CourseProductItemFooter/index.tsx +10 -14
  100. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/_styles.scss +8 -1
  101. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/CourseRunList.tsx +2 -2
  102. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/_styles.scss +9 -0
  103. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +116 -75
  104. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +6 -4
  105. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +29 -30
  106. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.stories.tsx +81 -0
  107. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx +36 -2
  108. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx +36 -2
  109. package/js/widgets/SyllabusCourseRunsList/index.spec.tsx +58 -8
  110. package/package.json +2 -1
  111. package/scss/colors/_theme.scss +3 -0
  112. package/scss/components/templates/richie/slider/_slider.scss +19 -0
  113. package/scss/objects/_blogpost_glimpses.scss +5 -0
  114. package/scss/objects/_course_glimpses.scss +16 -0
  115. package/js/hooks/useCourseProductRelation/index.ts +0 -44
@@ -1,6 +1,6 @@
1
1
  import { FormattedMessage, defineMessages } from 'react-intl';
2
2
  import PurchaseButton from 'components/PurchaseButton';
3
- import { CourseProductRelation, CredentialProduct } from 'types/Joanie';
3
+ import { Offering, CredentialProduct } from 'types/Joanie';
4
4
  import { PacedCourse } from 'types';
5
5
 
6
6
  const messages = defineMessages({
@@ -24,20 +24,16 @@ other {# remaining seats}
24
24
 
25
25
  interface CourseProductItemFooterProps {
26
26
  course: PacedCourse;
27
- courseProductRelation: CourseProductRelation;
27
+ offering: Offering;
28
28
  canPurchase: boolean;
29
29
  }
30
30
 
31
31
  const CourseProductItemFooter = ({
32
32
  course,
33
- courseProductRelation,
33
+ offering,
34
34
  canPurchase,
35
35
  }: CourseProductItemFooterProps) => {
36
- // eslint-disable-next-line @typescript-eslint/naming-convention
37
- const { seats, nb_seats_available } = courseProductRelation;
38
- const hasSeatsLimit = seats && nb_seats_available !== undefined;
39
- const hasNoSeatsAvailable = hasSeatsLimit && nb_seats_available === 0;
40
- if (hasNoSeatsAvailable)
36
+ if (!offering?.rules?.has_seats_left)
41
37
  return (
42
38
  <p className="product-widget__footer__message">
43
39
  <FormattedMessage {...messages.noSeatsAvailable} />
@@ -47,18 +43,18 @@ const CourseProductItemFooter = ({
47
43
  <div className="product-widget__footer__order-group">
48
44
  <PurchaseButton
49
45
  course={course}
50
- product={courseProductRelation.product as CredentialProduct}
51
- courseProductRelation={courseProductRelation}
52
- organizations={courseProductRelation.organizations}
53
- isWithdrawable={courseProductRelation.is_withdrawable}
46
+ product={offering.product as CredentialProduct}
47
+ offering={offering}
48
+ organizations={offering.organizations}
49
+ isWithdrawable={offering.is_withdrawable}
54
50
  disabled={!canPurchase}
55
51
  buttonProps={{ fullWidth: true }}
56
52
  />
57
- {hasSeatsLimit && (
53
+ {offering?.rules?.has_seat_limit && (
58
54
  <p className="product-widget__footer__message">
59
55
  <FormattedMessage
60
56
  {...messages.nbSeatsAvailable}
61
- values={{ nb: courseProductRelation.nb_seats_available }}
57
+ values={{ nb: offering.rules.nb_available_seats }}
62
58
  />
63
59
  </p>
64
60
  )}
@@ -67,6 +67,12 @@
67
67
  }
68
68
  }
69
69
 
70
+ & .product-widget__header-main {
71
+ display: flex;
72
+ justify-content: center;
73
+ text-align: center;
74
+ }
75
+
70
76
  & .product-widget__title {
71
77
  color: r-theme-val(product-item, light-color);
72
78
  font-size: 1.5rem;
@@ -76,7 +82,7 @@
76
82
  background-color: r-theme-val(product-item, light-color);
77
83
  border-radius: 100vw;
78
84
  color: r-theme-val(product-item, base-border);
79
- font-size: 0.81rem;
85
+ font-size: 1rem;
80
86
  margin-bottom: 0.3rem;
81
87
  padding: 0.375rem 0.81rem;
82
88
  white-space: nowrap;
@@ -90,6 +96,7 @@
90
96
 
91
97
  &-discount {
92
98
  color: r-theme-val(product-item, feedback-color);
99
+ text-decoration: none;
93
100
  }
94
101
  }
95
102
 
@@ -68,7 +68,7 @@ const CourseRunList = ({ courseRuns }: Props) => {
68
68
  </strong>
69
69
  <span
70
70
  data-testid={`course-run-${courseRun.id}-enrollment-dates`}
71
- className="course-runs-item__metadata"
71
+ className="course-runs-item__metadata course-runs-item__enrollment-date"
72
72
  >
73
73
  <EnrollmentDate
74
74
  enrollment_start={courseRun.enrollment_start}
@@ -77,7 +77,7 @@ const CourseRunList = ({ courseRuns }: Props) => {
77
77
  </span>
78
78
  <span
79
79
  data-testid={`course-run-${courseRun.id}-languages`}
80
- className="course-runs-item__metadata"
80
+ className="course-runs-item__metadata course-runs-item__languages"
81
81
  >
82
82
  <FormattedMessage
83
83
  {...sharedMessages.language}
@@ -113,6 +113,15 @@
113
113
  line-height: 1.4em;
114
114
  }
115
115
 
116
+ &__enrollment-date {
117
+ font-weight: bold;
118
+ color: #03317f;
119
+ }
120
+
121
+ &__languages {
122
+ font-weight: bold;
123
+ }
124
+
116
125
  &__feedback {
117
126
  color: r-theme-val(product-item, feedback-color);
118
127
  }
@@ -6,7 +6,7 @@ import {
6
6
  PacedCourseFactory,
7
7
  } from 'utils/test/factories/richie';
8
8
  import {
9
- CourseProductRelationFactory,
9
+ OfferingFactory,
10
10
  EnrollmentFactory,
11
11
  CredentialOrderFactory,
12
12
  ProductFactory,
@@ -74,8 +74,8 @@ describe('CourseProductItem', () => {
74
74
  }).format(price);
75
75
 
76
76
  it('should display a loader until product is loaded', async () => {
77
- const relation = CourseProductRelationFactory().one();
78
- const { product } = relation;
77
+ const offering = OfferingFactory().one();
78
+ const { product } = offering;
79
79
  const productDeferred = new Deferred();
80
80
  fetchMock.get(
81
81
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
@@ -92,16 +92,16 @@ describe('CourseProductItem', () => {
92
92
 
93
93
  // - A loader should be displayed while product information are fetching
94
94
  await expectSpinner('Loading product information...');
95
- productDeferred.resolve(relation);
95
+ productDeferred.resolve(offering);
96
96
  await expectNoSpinner('Loading product information...');
97
97
  });
98
98
 
99
99
  it('renders product information for anonymous user', async () => {
100
- const relation = CourseProductRelationFactory().one();
101
- const { product } = relation;
100
+ const offering = OfferingFactory().one();
101
+ const { product } = offering;
102
102
  fetchMock.get(
103
103
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
104
- relation,
104
+ offering,
105
105
  );
106
106
 
107
107
  render(
@@ -131,7 +131,7 @@ describe('CourseProductItem', () => {
131
131
  ).not.toBeInTheDocument();
132
132
 
133
133
  // - Render all target courses information
134
- relation.product.target_courses.forEach((course) => {
134
+ offering.product.target_courses.forEach((course) => {
135
135
  const $item = screen.getByTestId(`course-item-${course.code}`);
136
136
  // the course title shouldn't be a heading to prevent misdirection for screen reader users,
137
137
  // but we want to it to visually look like a h5
@@ -151,21 +151,23 @@ describe('CourseProductItem', () => {
151
151
  });
152
152
 
153
153
  it('renders discount rate for anonymous user', async () => {
154
- const relation = CourseProductRelationFactory({
154
+ const offering = OfferingFactory({
155
155
  product: CredentialProductFactory({
156
156
  price: 840,
157
157
  price_currency: 'EUR',
158
158
  }).one(),
159
- discounted_price: 800,
160
- discount_rate: 0.3,
161
- description: 'Year 2023 discount',
162
- discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
163
- discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
159
+ rules: {
160
+ discounted_price: 800,
161
+ discount_rate: 0.3,
162
+ description: 'Year 2023 discount',
163
+ discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
164
+ discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
165
+ },
164
166
  }).one();
165
- const { product } = relation;
167
+ const { product } = offering;
166
168
  fetchMock.get(
167
169
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
168
- relation,
170
+ offering,
169
171
  );
170
172
 
171
173
  render(
@@ -192,7 +194,7 @@ describe('CourseProductItem', () => {
192
194
  const discountedPriceLabel = screen.getByText('Discounted price:');
193
195
  expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
194
196
  const discountedPrice = screen.getByText(
195
- priceFormatter(product.price_currency, relation.discounted_price!).replace(
197
+ priceFormatter(product.price_currency, offering.rules!.discounted_price!).replace(
196
198
  /(\u202F|\u00a0)/g,
197
199
  ' ',
198
200
  ),
@@ -212,21 +214,23 @@ describe('CourseProductItem', () => {
212
214
  });
213
215
 
214
216
  it('renders discount amount for anonymous user', async () => {
215
- const relation = CourseProductRelationFactory({
217
+ const offering = OfferingFactory({
216
218
  product: CredentialProductFactory({
217
219
  price: 840,
218
220
  price_currency: 'EUR',
219
221
  }).one(),
220
- discounted_price: 800,
221
- discount_amount: 40,
222
- description: 'Year 2023 discount',
223
- discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
224
- discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
222
+ rules: {
223
+ discounted_price: 800,
224
+ discount_amount: 40,
225
+ description: 'Year 2023 discount',
226
+ discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
227
+ discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
228
+ },
225
229
  }).one();
226
- const { product } = relation;
230
+ const { product } = offering;
227
231
  fetchMock.get(
228
232
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
229
- relation,
233
+ offering,
230
234
  );
231
235
 
232
236
  render(
@@ -253,7 +257,7 @@ describe('CourseProductItem', () => {
253
257
  const discountedPriceLabel = screen.getByText('Discounted price:');
254
258
  expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
255
259
  const discountedPrice = screen.getByText(
256
- priceFormatter(product.price_currency, relation.discounted_price!).replace(
260
+ priceFormatter(product.price_currency, offering.rules!.discounted_price!).replace(
257
261
  /(\u202F|\u00a0)/g,
258
262
  ' ',
259
263
  ),
@@ -273,15 +277,15 @@ describe('CourseProductItem', () => {
273
277
  });
274
278
 
275
279
  it('does not render <CertificateItem /> if product do not have a certificate', async () => {
276
- const relation = CourseProductRelationFactory({
280
+ const offering = OfferingFactory({
277
281
  product: ProductFactory({
278
282
  certificate_definition: undefined,
279
283
  }).one(),
280
284
  }).one();
281
- const { product } = relation;
285
+ const { product } = offering;
282
286
  fetchMock.get(
283
287
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
284
- relation,
288
+ offering,
285
289
  );
286
290
 
287
291
  render(
@@ -300,16 +304,16 @@ describe('CourseProductItem', () => {
300
304
  });
301
305
 
302
306
  it('renders product information in compact mode', async () => {
303
- const relation = CourseProductRelationFactory().one();
307
+ const offering = OfferingFactory().one();
304
308
  fetchMock.get(
305
- `https://joanie.endpoint/api/v1.0/courses/00000/products/${relation.product.id}/`,
306
- relation,
309
+ `https://joanie.endpoint/api/v1.0/courses/00000/products/${offering.product.id}/`,
310
+ offering,
307
311
  );
308
312
 
309
313
  const { container } = render(
310
314
  <CourseProductItem
311
315
  course={PacedCourseFactory({ code: '00000' }).one()}
312
- productId={relation.product.id}
316
+ productId={offering.product.id}
313
317
  compact
314
318
  />,
315
319
  { queryOptions: { client: createTestQueryClient({ user: null }) } },
@@ -317,14 +321,14 @@ describe('CourseProductItem', () => {
317
321
 
318
322
  // In the header, we should display the product title, the product price
319
323
  // and product date range and languages
320
- await screen.findByRole('heading', { level: 3, name: relation.product.title });
324
+ await screen.findByRole('heading', { level: 3, name: offering.product.title });
321
325
  // the price shouldn't be a heading to prevent misdirection for screen reader users,
322
326
  // but we want to it to visually look like a h6
323
327
 
324
328
  const $price = screen.getByText(
325
329
  // the price formatter generates non-breaking spaces and getByText doesn't seem to handle that well, replace it
326
330
  // with a regular space. We replace NNBSP (\u202F) and NBSP (\u00a0) with a regular space
327
- priceFormatter(relation.product.price_currency, relation.product.price).replace(
331
+ priceFormatter(offering.product.price_currency, offering.product.price).replace(
328
332
  /(\u202F|\u00a0)/g,
329
333
  ' ',
330
334
  ),
@@ -340,7 +344,7 @@ describe('CourseProductItem', () => {
340
344
  expect($productWidgetContent).not.toBeInTheDocument();
341
345
 
342
346
  // - Any target courses information should be displayed
343
- relation.product.target_courses.forEach((course) => {
347
+ offering.product.target_courses.forEach((course) => {
344
348
  const $item = screen.queryByTestId(`course-item-${course.code}`);
345
349
  expect($item).not.toBeInTheDocument();
346
350
  });
@@ -349,7 +353,7 @@ describe('CourseProductItem', () => {
349
353
  expect(screen.queryByTestId('CertificateItem')).not.toBeInTheDocument();
350
354
 
351
355
  // - Render a login button
352
- screen.getByRole('button', { name: `Login to purchase "${relation.product.title}"` });
356
+ screen.getByRole('button', { name: `Login to purchase "${offering.product.title}"` });
353
357
  // - Does not render PurchaseButton cta
354
358
  expect(screen.queryByTestId('PurchaseButton__cta')).not.toBeInTheDocument();
355
359
  });
@@ -357,8 +361,8 @@ describe('CourseProductItem', () => {
357
361
  it.each([OrderState.PENDING, OrderState.NO_PAYMENT])(
358
362
  'renders product informations for %s order',
359
363
  async (state) => {
360
- const relation = CourseProductRelationFactory().one();
361
- const { product } = relation;
364
+ const offering = OfferingFactory().one();
365
+ const { product } = offering;
362
366
  const order = CredentialOrderFactory({
363
367
  product_id: product.id,
364
368
  course: PacedCourseFactory({ code: '00000' }).one(),
@@ -368,7 +372,7 @@ describe('CourseProductItem', () => {
368
372
 
369
373
  fetchMock.get(
370
374
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
371
- relation,
375
+ offering,
372
376
  );
373
377
  const orderQueryParameters = {
374
378
  course_code: order.course.code,
@@ -382,13 +386,13 @@ describe('CourseProductItem', () => {
382
386
  render(
383
387
  <CourseProductItem
384
388
  course={PacedCourseFactory({ code: '00000' }).one()}
385
- productId={relation.product.id}
389
+ productId={offering.product.id}
386
390
  />,
387
391
  );
388
392
 
389
393
  // In the header, we should display the product title, the product price
390
394
  // and product date range and languages
391
- await screen.findByRole('heading', { level: 3, name: relation.product.title });
395
+ await screen.findByRole('heading', { level: 3, name: offering.product.title });
392
396
  // the price shouldn't be a heading to prevent misdirection for screen reader users,
393
397
  // but we want to it to visually look like a h6
394
398
 
@@ -418,8 +422,8 @@ describe('CourseProductItem', () => {
418
422
  it.each([OrderState.PENDING, OrderState.NO_PAYMENT])(
419
423
  'renders product informations for %s order in compact mode',
420
424
  async (state) => {
421
- const relation = CourseProductRelationFactory().one();
422
- const { product } = relation;
425
+ const offering = OfferingFactory().one();
426
+ const { product } = offering;
423
427
  const order = CredentialOrderFactory({
424
428
  product_id: product.id,
425
429
  course: PacedCourseFactory({ code: '00000' }).one(),
@@ -429,7 +433,7 @@ describe('CourseProductItem', () => {
429
433
 
430
434
  fetchMock.get(
431
435
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
432
- relation,
436
+ offering,
433
437
  );
434
438
  const orderQueryParameters = {
435
439
  course_code: order.course.code,
@@ -443,14 +447,14 @@ describe('CourseProductItem', () => {
443
447
  render(
444
448
  <CourseProductItem
445
449
  course={PacedCourseFactory({ code: '00000' }).one()}
446
- productId={relation.product.id}
450
+ productId={offering.product.id}
447
451
  compact
448
452
  />,
449
453
  );
450
454
 
451
455
  // In the header, we should display the product title, the product price
452
456
  // and product date range and languages
453
- await screen.findByRole('heading', { level: 3, name: relation.product.title });
457
+ await screen.findByRole('heading', { level: 3, name: offering.product.title });
454
458
  // the price shouldn't be a heading to prevent misdirection for screen reader users,
455
459
  // but we want to it to visually look like a h6
456
460
 
@@ -460,7 +464,7 @@ describe('CourseProductItem', () => {
460
464
  expect($enrolledInfo.classList.contains('h6')).toBe(true);
461
465
 
462
466
  // - Any target courses information should be displayed
463
- relation.product.target_courses.forEach((course) => {
467
+ offering.product.target_courses.forEach((course) => {
464
468
  const $item = screen.queryByTestId(`course-item-${course.code}`);
465
469
  expect($item).not.toBeInTheDocument();
466
470
  });
@@ -471,8 +475,8 @@ describe('CourseProductItem', () => {
471
475
  );
472
476
 
473
477
  it.each(ENROLLABLE_ORDER_STATES)('renders product information for a %s order', async (state) => {
474
- const relation = CourseProductRelationFactory().one();
475
- const { product } = relation;
478
+ const offering = OfferingFactory().one();
479
+ const { product } = offering;
476
480
  const order = CredentialOrderFactory({
477
481
  product_id: product.id,
478
482
  course: PacedCourseFactory({ code: '00000' }).one(),
@@ -482,7 +486,7 @@ describe('CourseProductItem', () => {
482
486
 
483
487
  fetchMock.get(
484
488
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
485
- relation,
489
+ offering,
486
490
  );
487
491
  const orderQueryParameters = {
488
492
  course_code: order.course.code,
@@ -533,17 +537,17 @@ describe('CourseProductItem', () => {
533
537
  it.each(ENROLLABLE_ORDER_STATES)(
534
538
  'renders product informations for a %s order in compact mode',
535
539
  async (state) => {
536
- const relation = CourseProductRelationFactory().one();
540
+ const offering = OfferingFactory().one();
537
541
  const order: CredentialOrder = CredentialOrderFactory({
538
- product_id: relation.product.id,
542
+ product_id: offering.product.id,
539
543
  course: PacedCourseFactory({ code: '00000' }).one(),
540
- target_courses: relation.product.target_courses,
544
+ target_courses: offering.product.target_courses,
541
545
  state,
542
546
  }).one();
543
547
 
544
548
  fetchMock.get(
545
- `https://joanie.endpoint/api/v1.0/courses/00000/products/${relation.product.id}/`,
546
- relation,
549
+ `https://joanie.endpoint/api/v1.0/courses/00000/products/${offering.product.id}/`,
550
+ offering,
547
551
  );
548
552
  const orderQueryParameters = {
549
553
  product_id: order.product_id,
@@ -557,14 +561,14 @@ describe('CourseProductItem', () => {
557
561
 
558
562
  render(
559
563
  <CourseProductItem
560
- productId={relation.product.id}
564
+ productId={offering.product.id}
561
565
  course={PacedCourseFactory({ code: '00000' }).one()}
562
566
  compact
563
567
  />,
564
568
  );
565
569
 
566
570
  // Wait for product information to be fetched
567
- await screen.findByRole('heading', { level: 3, name: relation.product.title });
571
+ await screen.findByRole('heading', { level: 3, name: offering.product.title });
568
572
 
569
573
  // - In place of product price, a label should be displayed
570
574
  const $enrolledInfo = await screen.findByText('Purchased');
@@ -601,8 +605,8 @@ describe('CourseProductItem', () => {
601
605
  );
602
606
 
603
607
  it('renders enrollment information when user is enrolled to a course run', async () => {
604
- const relation = CourseProductRelationFactory().one();
605
- const { product } = relation;
608
+ const offering = OfferingFactory().one();
609
+ const { product } = offering;
606
610
  // - Create an order with an active enrollment
607
611
  const enrollment: Enrollment = EnrollmentFactory({
608
612
  course_run: product.target_courses[0]!.course_runs[0]! as CourseRun,
@@ -616,7 +620,7 @@ describe('CourseProductItem', () => {
616
620
 
617
621
  fetchMock.get(
618
622
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
619
- relation,
623
+ offering,
620
624
  );
621
625
  const orderQueryParameters = {
622
626
  product_id: order.product_id,
@@ -668,8 +672,8 @@ describe('CourseProductItem', () => {
668
672
  it.each(PURCHASABLE_ORDER_STATES)(
669
673
  'renders sale tunnel button if user already has a %s order',
670
674
  async (state) => {
671
- const relation = CourseProductRelationFactory().one();
672
- const { product } = relation;
675
+ const offering = OfferingFactory().one();
676
+ const { product } = offering;
673
677
  const order = CredentialOrderFactory({
674
678
  product_id: product.id,
675
679
  course: PacedCourseFactory({ code: '00000' }).one(),
@@ -678,7 +682,7 @@ describe('CourseProductItem', () => {
678
682
  }).one();
679
683
  fetchMock.get(
680
684
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
681
- relation,
685
+ offering,
682
686
  );
683
687
  const orderQueryParameters = {
684
688
  product_id: order.product_id,
@@ -709,7 +713,7 @@ describe('CourseProductItem', () => {
709
713
  expect($price.classList.contains('h6')).toBe(true);
710
714
 
711
715
  // - Render all target courses information
712
- relation.product.target_courses.forEach((course) => {
716
+ offering.product.target_courses.forEach((course) => {
713
717
  const $item = screen.getByTestId(`course-item-${course.code}`);
714
718
  // the course title shouldn't be a heading to prevent misdirection for screen reader users,
715
719
  // but we want to it to visually look like a h5
@@ -724,11 +728,11 @@ describe('CourseProductItem', () => {
724
728
  );
725
729
 
726
730
  it('renders sale tunnel button if user already has a canceled order', async () => {
727
- const relation = CourseProductRelationFactory().one();
728
- const { product } = relation;
731
+ const offering = OfferingFactory().one();
732
+ const { product } = offering;
729
733
  fetchMock.get(
730
734
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
731
- relation,
735
+ offering,
732
736
  );
733
737
  const orderQueryParameters = {
734
738
  product_id: product.id,
@@ -759,7 +763,7 @@ describe('CourseProductItem', () => {
759
763
  expect($price.classList.contains('h6')).toBe(true);
760
764
 
761
765
  // - Render all target courses information
762
- relation.product.target_courses.forEach((course) => {
766
+ offering.product.target_courses.forEach((course) => {
763
767
  const $item = screen.getByTestId(`course-item-${course.code}`);
764
768
  // the course title shouldn't be a heading to prevent misdirection for screen reader users,
765
769
  // but we want to it to visually look like a h5
@@ -773,7 +777,7 @@ describe('CourseProductItem', () => {
773
777
  });
774
778
 
775
779
  it('renders error message when product fetching has failed', async () => {
776
- const { product } = CourseProductRelationFactory().one();
780
+ const { product } = OfferingFactory().one();
777
781
 
778
782
  fetchMock.get(
779
783
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
@@ -794,11 +798,13 @@ describe('CourseProductItem', () => {
794
798
  });
795
799
 
796
800
  it('renders a warning message that tells that no seats are left', async () => {
797
- const relation = CourseProductRelationFactory({
798
- seats: 2,
799
- nb_seats_available: 0,
801
+ const offering = OfferingFactory({
802
+ rules: {
803
+ nb_available_seats: 0,
804
+ has_seats_left: false,
805
+ },
800
806
  }).one();
801
- const { product } = relation;
807
+ const { product } = offering;
802
808
  const order = CredentialOrderFactory({
803
809
  product_id: product.id,
804
810
  course: PacedCourseFactory({ code: '00000' }).one(),
@@ -807,7 +813,7 @@ describe('CourseProductItem', () => {
807
813
  }).one();
808
814
  fetchMock.get(
809
815
  `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
810
- relation,
816
+ offering,
811
817
  );
812
818
  const orderQueryParameters = {
813
819
  product_id: order.product_id,
@@ -832,4 +838,39 @@ describe('CourseProductItem', () => {
832
838
  expect(screen.queryByRole('button', { name: product.call_to_action })).not.toBeInTheDocument();
833
839
  screen.getByText('Sorry, no seats available for now');
834
840
  });
841
+
842
+ it('renders product information without rules in offering', async () => {
843
+ const offering = OfferingFactory({
844
+ product: CredentialProductFactory({
845
+ price: 840,
846
+ price_currency: 'EUR',
847
+ }).one(),
848
+ rules: undefined,
849
+ }).one();
850
+ const { product } = offering;
851
+ fetchMock.get(
852
+ `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
853
+ offering,
854
+ );
855
+
856
+ render(
857
+ <CourseProductItem
858
+ course={PacedCourseFactory({ code: '00000' }).one()}
859
+ productId={product.id}
860
+ />,
861
+ { queryOptions: { client: createTestQueryClient({ user: null }) } },
862
+ );
863
+
864
+ // Wait for product information to be fetched
865
+ await screen.findByRole('heading', { level: 3, name: product.title });
866
+
867
+ // Expect to render the component without rules information
868
+ expect(
869
+ screen.getByText(
870
+ priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
871
+ ),
872
+ ).toBeInTheDocument();
873
+ expect(document.querySelector('.product-widget__price-discounted')).not.toBeInTheDocument();
874
+ expect(screen.getByText('Sorry, no seats available for now')).toBeInTheDocument();
875
+ });
835
876
  });
@@ -3,7 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
3
3
  import fetchMock from 'fetch-mock';
4
4
  import { StorybookHelper } from 'utils/StorybookHelper';
5
5
  import {
6
- CourseProductRelationFactory,
6
+ OfferingFactory,
7
7
  CourseRunFactory,
8
8
  CredentialOrderFactory,
9
9
  CredentialProductFactory,
@@ -22,13 +22,15 @@ const render = (args: CourseProductItemProps, options?: Maybe<{ order: Credentia
22
22
  fetchMock.get(`http://localhost:8071/api/v1.0/addresses/`, [], { overwriteRoutes: true });
23
23
  fetchMock.get(
24
24
  `http://localhost:8071/api/v1.0/courses/${args.course.code}/products/${args.productId}/`,
25
- CourseProductRelationFactory({
25
+ OfferingFactory({
26
26
  product: CredentialProductFactory({
27
27
  price: 840,
28
28
  price_currency: 'EUR',
29
29
  }).one(),
30
- discounted_price: 800,
31
- discount_rate: 0.3,
30
+ rules: {
31
+ discounted_price: 800,
32
+ discount_rate: 0.3,
33
+ },
32
34
  }).one(),
33
35
  { overwriteRoutes: true },
34
36
  );