richie-education 3.1.2 → 3.1.3-dev11

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.
@@ -42,7 +42,7 @@ const messages = defineMessages({
42
42
 
43
43
  interface PurchaseButtonPropsBase {
44
44
  product: Joanie.CredentialProduct | Joanie.CertificateProduct;
45
- orderGroup?: Joanie.OrderGroup;
45
+ courseProductRelation?: Joanie.CourseProductRelation;
46
46
  isWithdrawable: boolean;
47
47
  disabled?: boolean;
48
48
  className?: string;
@@ -66,8 +66,8 @@ interface CertificatePurchaseButtonProps extends PurchaseButtonPropsBase {
66
66
  const PurchaseButton = ({
67
67
  product,
68
68
  course,
69
+ courseProductRelation,
69
70
  enrollment,
70
- orderGroup,
71
71
  isWithdrawable,
72
72
  organizations,
73
73
  disabled = false,
@@ -140,8 +140,8 @@ const PurchaseButton = ({
140
140
  {...saleTunnelModal}
141
141
  product={product}
142
142
  organizations={organizations}
143
+ courseProductRelation={courseProductRelation}
143
144
  enrollment={enrollment}
144
- orderGroup={orderGroup}
145
145
  course={course}
146
146
  isWithdrawable={isWithdrawable}
147
147
  onFinish={onFinish}
@@ -19,9 +19,7 @@ export const CredentialSaleTunnel = (props: CredentialSaleTunnelProps) => {
19
19
  );
20
20
  };
21
21
 
22
- const CredentialPaymentButton = ({
23
- course,
24
- }: Pick<CredentialSaleTunnelProps, 'course' | 'orderGroup'>) => {
22
+ const CredentialPaymentButton = ({ course }: Pick<CredentialSaleTunnelProps, 'course'>) => {
25
23
  return (
26
24
  <SubscriptionButton
27
25
  buildOrderPayload={(payload) => ({
@@ -10,7 +10,14 @@ import {
10
10
  } from 'react';
11
11
  import { SaleTunnelSponsors } from 'components/SaleTunnel/Sponsors/SaleTunnelSponsors';
12
12
  import { SaleTunnelProps } from 'components/SaleTunnel/index';
13
- import { Address, CreditCard, Order, OrderState, Product } from 'types/Joanie';
13
+ import {
14
+ Address,
15
+ CourseProductRelation,
16
+ CreditCard,
17
+ Order,
18
+ OrderState,
19
+ Product,
20
+ } from 'types/Joanie';
14
21
  import useProductOrder from 'hooks/useProductOrder';
15
22
  import { SaleTunnelSuccess } from 'components/SaleTunnel/SaleTunnelSuccess';
16
23
  import WebAnalyticsAPIHandler from 'api/web-analytics';
@@ -26,6 +33,7 @@ export interface SaleTunnelContextType {
26
33
  order?: Order;
27
34
  product: Product;
28
35
  webAnalyticsEventKey: string;
36
+ relation?: CourseProductRelation;
29
37
 
30
38
  // internal
31
39
  step: SaleTunnelStep;
@@ -113,6 +121,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
113
121
  webAnalyticsEventKey: props.eventKey,
114
122
  order,
115
123
  product: props.product,
124
+ relation: props.courseProductRelation,
116
125
  props,
117
126
  billingAddress,
118
127
  setBillingAddress,
@@ -99,7 +99,7 @@ const Email = () => {
99
99
  };
100
100
 
101
101
  const Total = () => {
102
- const { product } = useSaleTunnelContext();
102
+ const { product, relation } = useSaleTunnelContext();
103
103
  return (
104
104
  <div className="sale-tunnel__total">
105
105
  <div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
@@ -108,7 +108,7 @@ const Total = () => {
108
108
  </div>
109
109
  <div className="block-title">
110
110
  <FormattedNumber
111
- value={product.price}
111
+ value={relation?.discounted_price || product.price}
112
112
  style="currency"
113
113
  currency={product.price_currency}
114
114
  />
@@ -72,7 +72,7 @@ interface Props {
72
72
  buildOrderPayload: (
73
73
  payload: Pick<
74
74
  OrderCreationPayload,
75
- 'product_id' | 'billing_address' | 'order_group_id' | 'has_waived_withdrawal_right'
75
+ 'product_id' | 'billing_address' | 'has_waived_withdrawal_right'
76
76
  >,
77
77
  ) => OrderCreationPayload;
78
78
  }
@@ -124,7 +124,6 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
124
124
  const payload = buildOrderPayload({
125
125
  product_id: product.id,
126
126
  billing_address: billingAddress!,
127
- order_group_id: saleTunnelProps.orderGroup?.id,
128
127
  has_waived_withdrawal_right: hasWaivedWithdrawalRight,
129
128
  });
130
129
 
@@ -1,5 +1,5 @@
1
1
  import fetchMock from 'fetch-mock';
2
- import { act, fireEvent, screen, waitFor } from '@testing-library/react';
2
+ import { screen } from '@testing-library/react';
3
3
  import queryString from 'query-string';
4
4
  import {
5
5
  RichieContextFactory as mockRichieContextFactory,
@@ -11,11 +11,9 @@ import {
11
11
  AddressFactory,
12
12
  CredentialOrderFactory,
13
13
  CredentialProductFactory,
14
- OrderGroupFactory,
15
14
  } from 'utils/test/factories/joanie';
16
15
  import type * as Joanie from 'types/Joanie';
17
- import { Maybe } from 'types/utils';
18
- import { NOT_CANCELED_ORDER_STATES, OrderCredentialCreationPayload } from 'types/Joanie';
16
+ import { NOT_CANCELED_ORDER_STATES } from 'types/Joanie';
19
17
  import { SaleTunnel, SaleTunnelProps } from 'components/SaleTunnel/index';
20
18
  import { render } from 'utils/test/render';
21
19
  import { getAddressLabel } from 'components/SaleTunnel/AddressSelector';
@@ -83,11 +81,9 @@ describe('SaleTunnel / Credential', () => {
83
81
  it('should create an order with an order group', async () => {
84
82
  const course = PacedCourseFactory().one();
85
83
  const product = CredentialProductFactory().one();
86
- const orderGroup = OrderGroupFactory().one();
87
84
  const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
88
85
 
89
- let createOrderPayload: Maybe<OrderCredentialCreationPayload>;
90
- const order = CredentialOrderFactory({ order_group_id: orderGroup.id }).one();
86
+ const order = CredentialOrderFactory().one();
91
87
  const orderQueryParameters = {
92
88
  course_code: course.code,
93
89
  product_id: product.id,
@@ -100,15 +96,12 @@ describe('SaleTunnel / Credential', () => {
100
96
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
101
97
  [],
102
98
  )
103
- .post('https://joanie.endpoint/api/v1.0/orders/', (_, { body }) => {
104
- createOrderPayload = JSON.parse(body as any);
105
- return order;
106
- })
99
+ .post('https://joanie.endpoint/api/v1.0/orders/', order)
107
100
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
108
101
  overwriteRoutes: true,
109
102
  });
110
103
 
111
- render(<Wrapper product={product} course={course} orderGroup={orderGroup} />, {
104
+ render(<Wrapper product={product} course={course} />, {
112
105
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
113
106
  });
114
107
 
@@ -121,12 +114,5 @@ describe('SaleTunnel / Credential', () => {
121
114
 
122
115
  // - Payment button should not be disabled.
123
116
  expect($button.disabled).toBe(false);
124
-
125
- // - User clicks on pay button
126
- await act(async () => {
127
- fireEvent.click($button);
128
- });
129
-
130
- await waitFor(() => expect(createOrderPayload?.order_group_id).toEqual(orderGroup.id));
131
117
  });
132
118
  });
@@ -15,6 +15,7 @@ import {
15
15
  AddressFactory,
16
16
  CertificateOrderFactory,
17
17
  CertificateProductFactory,
18
+ CourseProductRelationFactory,
18
19
  CredentialOrderFactory,
19
20
  CredentialProductFactory,
20
21
  CreditCardFactory,
@@ -432,6 +433,80 @@ describe.each([
432
433
  name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
433
434
  });
434
435
  });
436
+
437
+ const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
438
+ expect($totalAmount).toHaveTextContent(
439
+ 'Total' + formatPrice(product.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
440
+ );
441
+ });
442
+
443
+ it('should show the product payment schedule with discounted price', async () => {
444
+ const intl = createIntl({ locale: 'en' });
445
+ const schedule = PaymentInstallmentFactory().many(2);
446
+
447
+ const relation = CourseProductRelationFactory({
448
+ product: ProductFactory({
449
+ price: 840,
450
+ price_currency: 'EUR',
451
+ }).one(),
452
+ discounted_price: 800,
453
+ discount_rate: 0.3,
454
+ }).one();
455
+ const { product } = relation;
456
+
457
+ fetchMock
458
+ .get(
459
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
460
+ [],
461
+ )
462
+ .get(
463
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
464
+ schedule,
465
+ );
466
+
467
+ render(<Wrapper product={product} courseProductRelation={relation} isWithdrawable={true} />, {
468
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
469
+ });
470
+
471
+ await screen.findByRole('heading', {
472
+ level: 4,
473
+ name: 'Payment schedule',
474
+ });
475
+
476
+ const scheduleTable = screen.getByRole('table');
477
+ const scheduleTableRows = within(scheduleTable).getAllByRole('row');
478
+ expect(scheduleTableRows).toHaveLength(schedule.length);
479
+
480
+ scheduleTableRows.forEach((row, index) => {
481
+ const installment = schedule[index];
482
+ // A first column should show the installment index
483
+ within(row).getByRole('cell', {
484
+ name: (index + 1).toString(),
485
+ });
486
+ // A 2nd column should show the installment amount
487
+ within(row).getByRole('cell', {
488
+ name: formatPrice(installment.amount, installment.currency),
489
+ });
490
+ // A 3rd column should show the installment withdraw date
491
+ within(row).getByRole('cell', {
492
+ name: `Withdrawn on ${intl.formatDate(installment.due_date, {
493
+ ...DEFAULT_DATE_FORMAT,
494
+ })}`,
495
+ });
496
+ // A 4th column should show the installment state
497
+ within(row).getByRole('cell', {
498
+ name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
499
+ });
500
+ });
501
+
502
+ const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
503
+ expect($totalAmount).toHaveTextContent(
504
+ 'Total' +
505
+ formatPrice(relation!.discounted_price!, product.price_currency).replace(
506
+ /(\u202F|\u00a0)/g,
507
+ ' ',
508
+ ),
509
+ );
435
510
  });
436
511
 
437
512
  it('should show a walkthrough to explain the subscription process', async () => {
@@ -15,7 +15,6 @@ export default {
15
15
  isWithdrawable: true,
16
16
  // enrollment?: Enrollment;
17
17
  // product: CredentialProduct | CertificateProduct;
18
- // orderGroup?: OrderGroup;
19
18
  // onFinish?: (order: Order) => void;
20
19
  };
21
20
  return (
@@ -2,10 +2,10 @@ import { ModalProps } from '@openfun/cunningham-react';
2
2
  import {
3
3
  CertificateProduct,
4
4
  CourseLight,
5
+ CourseProductRelation,
5
6
  CredentialProduct,
6
7
  Enrollment,
7
8
  Order,
8
- OrderGroup,
9
9
  Organization,
10
10
  Product,
11
11
  ProductType,
@@ -16,11 +16,11 @@ import { PacedCourse } from 'types';
16
16
 
17
17
  export interface SaleTunnelProps extends Pick<ModalProps, 'isOpen' | 'onClose'> {
18
18
  product: Product;
19
+ courseProductRelation?: CourseProductRelation;
19
20
  organizations?: Organization[];
20
21
  isWithdrawable: boolean;
21
22
  course?: PacedCourse | CourseLight;
22
23
  enrollment?: Enrollment;
23
- orderGroup?: OrderGroup;
24
24
  onFinish?: (order: Order) => void;
25
25
  }
26
26
 
@@ -183,8 +183,15 @@ export interface CourseProductRelationLight {
183
183
  }
184
184
 
185
185
  export interface CourseProductRelation extends CourseProductRelationLight {
186
- order_groups: OrderGroup[];
187
186
  is_withdrawable: boolean;
187
+ discounted_price: Nullable<number>;
188
+ discount_rate: Nullable<number>;
189
+ discount_amount: Nullable<number>;
190
+ discount_start: Nullable<string>;
191
+ discount_end: Nullable<string>;
192
+ description: Nullable<string>;
193
+ nb_seats_available: Nullable<number>;
194
+ seats: Nullable<number>;
188
195
  }
189
196
  export function isCourseProductRelation(
190
197
  entity: CourseListItem | CourseProductRelationLight | RichieCourse,
@@ -325,7 +332,6 @@ export interface Order {
325
332
  enrollment: Nullable<EnrollmentLight>;
326
333
  organization_id: Organization['id'];
327
334
  organization: Organization;
328
- order_group_id?: OrderGroup['id'];
329
335
  payment_schedule?: PaymentSchedule;
330
336
  credit_card_id?: CreditCard['id'];
331
337
  }
@@ -403,13 +409,6 @@ export interface CourseOrderResourceQuery extends PaginatedResourceQuery {
403
409
  product_id?: Product['id'];
404
410
  }
405
411
 
406
- export interface OrderGroup {
407
- id: string;
408
- is_active: boolean;
409
- nb_seats: number;
410
- nb_available_seats: number;
411
- }
412
-
413
412
  export enum CreditCardBrand {
414
413
  MASTERCARD = 'mastercard',
415
414
  MAESTRO = 'maestro',
@@ -481,7 +480,6 @@ export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'>
481
480
 
482
481
  interface AbstractOrderProductCreationPayload {
483
482
  product_id: Product['id'];
484
- order_group_id?: OrderGroup['id'];
485
483
  billing_address: Omit<Address, 'id' | 'is_main'>;
486
484
  has_waived_withdrawal_right: boolean;
487
485
  }
@@ -1,5 +1,5 @@
1
1
  import { IntlShape } from 'react-intl';
2
- import { CourseProductRelation, Product, TargetCourse } from 'types/Joanie';
2
+ import { Product, TargetCourse } from 'types/Joanie';
3
3
  import { Maybe } from 'types/utils';
4
4
  import { IntlHelper } from 'utils/IntlHelper';
5
5
  import * as Joanie from 'types/Joanie';
@@ -44,10 +44,6 @@ export class ProductHelper {
44
44
  return IntlHelper.getLocalizedLanguages(uniqueLanguages, intl);
45
45
  }
46
46
 
47
- static getActiveOrderGroups(courseProductRelation: CourseProductRelation) {
48
- return courseProductRelation.order_groups?.filter((orderGroup) => orderGroup.is_active);
49
- }
50
-
51
47
  static hasRemainingSeats(product: Maybe<Product>) {
52
48
  if (!product) return false;
53
49
  return typeof product?.remaining_order_count !== 'number' || product.remaining_order_count > 0;
@@ -28,7 +28,6 @@ import {
28
28
  NestedCredentialOrder,
29
29
  Order,
30
30
  OrderEnrollment,
31
- OrderGroup,
32
31
  PaymentInstallment,
33
32
  OrderLite,
34
33
  OrderState,
@@ -197,7 +196,7 @@ export const CredentialProductFactory = factory((): CredentialProduct => {
197
196
  created_on: faker.date.past().toISOString(),
198
197
  title: FactoryHelper.sequence((counter) => `Certificate Product ${counter}`),
199
198
  type: ProductType.CREDENTIAL,
200
- price: faker.number.int(),
199
+ price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
201
200
  price_currency: faker.finance.currencyCode(),
202
201
  call_to_action: faker.lorem.words(3),
203
202
  certificate_definition: CertificationDefinitionFactory().one(),
@@ -294,25 +293,6 @@ export const CourseLightFactory = factory((): CourseLight => {
294
293
  };
295
294
  });
296
295
 
297
- export const OrderGroupFactory = factory((): OrderGroup => {
298
- const seats = faker.number.int({ min: 5, max: 100 });
299
- return {
300
- id: faker.string.uuid(),
301
- is_active: true,
302
- nb_seats: seats,
303
- nb_available_seats: faker.number.int({ min: 2, max: seats }),
304
- };
305
- });
306
-
307
- export const OrderGroupFullFactory = factory((): OrderGroup => {
308
- return {
309
- id: faker.string.uuid(),
310
- is_active: true,
311
- nb_seats: faker.number.int({ min: 5, max: 100 }),
312
- nb_available_seats: 0,
313
- };
314
- });
315
-
316
296
  export const NestedCourseOrderFactory = factory((): NestedCourseOrder => {
317
297
  return {
318
298
  id: faker.string.uuid(),
@@ -336,8 +316,15 @@ export const CourseProductRelationFactory = factory((): CourseProductRelation =>
336
316
  course: CourseFactory().one(),
337
317
  product: ProductFactory().one(),
338
318
  organizations: OrganizationFactory().many(1),
339
- order_groups: [],
340
319
  is_withdrawable: true,
320
+ discounted_price: null,
321
+ discount_rate: null,
322
+ discount_amount: null,
323
+ discount_start: null,
324
+ discount_end: null,
325
+ description: null,
326
+ seats: null,
327
+ nb_seats_available: null,
341
328
  };
342
329
  });
343
330
 
@@ -1,6 +1,6 @@
1
1
  import { FormattedMessage, defineMessages } from 'react-intl';
2
2
  import PurchaseButton from 'components/PurchaseButton';
3
- import { CourseProductRelation, CredentialProduct, OrderGroup } from 'types/Joanie';
3
+ import { CourseProductRelation, CredentialProduct } from 'types/Joanie';
4
4
  import { PacedCourse } from 'types';
5
5
 
6
6
  const messages = defineMessages({
@@ -26,54 +26,43 @@ interface CourseProductItemFooterProps {
26
26
  course: PacedCourse;
27
27
  courseProductRelation: CourseProductRelation;
28
28
  canPurchase: boolean;
29
- orderGroups: OrderGroup[];
30
- orderGroupsAvailable: OrderGroup[];
31
29
  }
32
30
 
33
31
  const CourseProductItemFooter = ({
34
32
  course,
35
33
  courseProductRelation,
36
- orderGroups,
37
- orderGroupsAvailable,
38
34
  canPurchase,
39
35
  }: CourseProductItemFooterProps) => {
40
- if (orderGroups.length === 0) {
41
- return (
42
- <PurchaseButton
43
- course={course}
44
- product={courseProductRelation.product as CredentialProduct}
45
- organizations={courseProductRelation.organizations}
46
- isWithdrawable={courseProductRelation.is_withdrawable}
47
- disabled={!canPurchase}
48
- buttonProps={{ fullWidth: true }}
49
- />
50
- );
51
- }
52
- if (orderGroupsAvailable.length === 0) {
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)
53
41
  return (
54
42
  <p className="product-widget__footer__message">
55
43
  <FormattedMessage {...messages.noSeatsAvailable} />
56
44
  </p>
57
45
  );
58
- }
59
- return orderGroupsAvailable.map((orderGroup) => (
60
- <div className="product-widget__footer__order-group" key={orderGroup.id}>
46
+ return (
47
+ <div className="product-widget__footer__order-group">
61
48
  <PurchaseButton
62
49
  course={course}
63
50
  product={courseProductRelation.product as CredentialProduct}
51
+ courseProductRelation={courseProductRelation}
64
52
  organizations={courseProductRelation.organizations}
65
53
  isWithdrawable={courseProductRelation.is_withdrawable}
66
54
  disabled={!canPurchase}
67
- orderGroup={orderGroup}
68
55
  buttonProps={{ fullWidth: true }}
69
56
  />
70
- <p className="product-widget__footer__message">
71
- <FormattedMessage
72
- {...messages.nbSeatsAvailable}
73
- values={{ nb: orderGroup.nb_available_seats }}
74
- />
75
- </p>
57
+ {hasSeatsLimit && (
58
+ <p className="product-widget__footer__message">
59
+ <FormattedMessage
60
+ {...messages.nbSeatsAvailable}
61
+ values={{ nb: courseProductRelation.nb_seats_available }}
62
+ />
63
+ </p>
64
+ )}
76
65
  </div>
77
- ));
66
+ );
78
67
  };
79
68
  export default CourseProductItemFooter;
@@ -43,6 +43,7 @@
43
43
  background-color: r-theme-val(product-item, base-border);
44
44
  color: r-theme-val(product-item, light-color);
45
45
  justify-content: space-between;
46
+ text-align: center;
46
47
  padding: 1rem 0.5rem;
47
48
 
48
49
  &-main {
@@ -51,6 +52,8 @@
51
52
  font-family: $r-font-family-montserrat;
52
53
  font-weight: bold;
53
54
  justify-content: space-between;
55
+ text-align: initial;
56
+ margin-bottom: rem-calc(8px);
54
57
  }
55
58
 
56
59
  &-metadata {
@@ -74,10 +77,30 @@
74
77
  border-radius: 100vw;
75
78
  color: r-theme-val(product-item, base-border);
76
79
  font-size: 0.81rem;
77
- margin-bottom: 0;
78
- margin-left: rem-calc(8px);
80
+ margin-bottom: 0.3rem;
79
81
  padding: 0.375rem 0.81rem;
80
82
  white-space: nowrap;
83
+ display: inline-block;
84
+
85
+ &-discounted {
86
+ text-decoration: line-through;
87
+ margin-right: rem-calc(6px);
88
+ font-size: 0.73rem;
89
+ }
90
+
91
+ &-discount {
92
+ color: r-theme-val(product-item, feedback-color);
93
+ }
94
+ }
95
+
96
+ &-description,
97
+ &-discount {
98
+ font-size: 1.1rem;
99
+ margin: 0;
100
+ }
101
+
102
+ &-discount {
103
+ font-weight: bold;
81
104
  }
82
105
  }
83
106
 
@@ -149,13 +172,10 @@
149
172
 
150
173
  &__order-group {
151
174
  text-align: center;
175
+ margin-bottom: 0.5rem;
152
176
 
153
177
  .product-widget__footer__message {
154
- margin: 0.5rem 0;
155
- }
156
-
157
- &:last-child {
158
- margin-bottom: -1rem;
178
+ margin: 0.5rem 0 0;
159
179
  }
160
180
  }
161
181
  }
@@ -38,7 +38,7 @@ const CourseRunList = ({ courseRuns }: Props) => {
38
38
  <ol className="course-runs-list">
39
39
  {Children.toArray(
40
40
  courseRuns.map((courseRun) => (
41
- <li className="course-runs-item course-runs-item--inactive">
41
+ <li key={courseRun.id} className="course-runs-item course-runs-item--inactive">
42
42
  <strong className="course-runs-item__course-dates">
43
43
  <span
44
44
  className="offscreen"
@@ -10,8 +10,7 @@ import {
10
10
  EnrollmentFactory,
11
11
  CredentialOrderFactory,
12
12
  ProductFactory,
13
- OrderGroupFullFactory,
14
- OrderGroupFactory,
13
+ CredentialProductFactory,
15
14
  } from 'utils/test/factories/joanie';
16
15
  import {
17
16
  CourseRun,
@@ -151,6 +150,128 @@ describe('CourseProductItem', () => {
151
150
  expect(screen.queryByTestId('PurchaseButton__cta')).toBeNull();
152
151
  });
153
152
 
153
+ it('renders discount rate for anonymous user', async () => {
154
+ const relation = CourseProductRelationFactory({
155
+ product: CredentialProductFactory({
156
+ price: 840,
157
+ price_currency: 'EUR',
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(),
164
+ }).one();
165
+ const { product } = relation;
166
+ fetchMock.get(
167
+ `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
168
+ relation,
169
+ );
170
+
171
+ render(
172
+ <CourseProductItem
173
+ course={PacedCourseFactory({ code: '00000' }).one()}
174
+ productId={product.id}
175
+ />,
176
+ { queryOptions: { client: createTestQueryClient({ user: null }) } },
177
+ );
178
+
179
+ await screen.findByRole('heading', { level: 3, name: product.title });
180
+
181
+ // - Render discount information
182
+ // Original price should be displayed as a del element
183
+ const originalPriceLabel = screen.getByText('Original price:');
184
+ expect(originalPriceLabel.classList.contains('offscreen')).toBe(true);
185
+ const originalPrice = screen.getByText(
186
+ priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
187
+ );
188
+ expect(originalPrice.tagName).toBe('DEL');
189
+ expect(originalPrice.getAttribute('aria-describedby')).toEqual(originalPriceLabel.id);
190
+
191
+ // Discounted price should be displayed as an ins element
192
+ const discountedPriceLabel = screen.getByText('Discounted price:');
193
+ expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
194
+ const discountedPrice = screen.getByText(
195
+ priceFormatter(product.price_currency, relation.discounted_price!).replace(
196
+ /(\u202F|\u00a0)/g,
197
+ ' ',
198
+ ),
199
+ );
200
+ expect(discountedPrice.tagName).toBe('INS');
201
+ expect(discountedPrice.getAttribute('aria-describedby')).toEqual(discountedPriceLabel.id);
202
+
203
+ // Discount description should be displayed
204
+ screen.getByText('Year 2023 discount');
205
+
206
+ // Discount rate should be displayed
207
+ screen.getByText('-30%');
208
+
209
+ // Discount date range should be displayed
210
+ screen.getByText('from Jan 01, 2023');
211
+ screen.getByText('to Dec 31, 2023');
212
+ });
213
+
214
+ it('renders discount amount for anonymous user', async () => {
215
+ const relation = CourseProductRelationFactory({
216
+ product: CredentialProductFactory({
217
+ price: 840,
218
+ price_currency: 'EUR',
219
+ }).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(),
225
+ }).one();
226
+ const { product } = relation;
227
+ fetchMock.get(
228
+ `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
229
+ relation,
230
+ );
231
+
232
+ render(
233
+ <CourseProductItem
234
+ course={PacedCourseFactory({ code: '00000' }).one()}
235
+ productId={product.id}
236
+ />,
237
+ { queryOptions: { client: createTestQueryClient({ user: null }) } },
238
+ );
239
+
240
+ await screen.findByRole('heading', { level: 3, name: product.title });
241
+
242
+ // - Render discount information
243
+ // Original price should be displayed as a del element
244
+ const originalPriceLabel = screen.getByText('Original price:');
245
+ expect(originalPriceLabel.classList.contains('offscreen')).toBe(true);
246
+ const originalPrice = screen.getByText(
247
+ priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
248
+ );
249
+ expect(originalPrice.tagName).toBe('DEL');
250
+ expect(originalPrice.getAttribute('aria-describedby')).toEqual(originalPriceLabel.id);
251
+
252
+ // Discounted price should be displayed as an ins element
253
+ const discountedPriceLabel = screen.getByText('Discounted price:');
254
+ expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
255
+ const discountedPrice = screen.getByText(
256
+ priceFormatter(product.price_currency, relation.discounted_price!).replace(
257
+ /(\u202F|\u00a0)/g,
258
+ ' ',
259
+ ),
260
+ );
261
+ expect(discountedPrice.tagName).toBe('INS');
262
+ expect(discountedPrice.getAttribute('aria-describedby')).toEqual(discountedPriceLabel.id);
263
+
264
+ // Discount description should be displayed
265
+ screen.getByText('Year 2023 discount');
266
+
267
+ // Discount rate should be displayed
268
+ screen.getByText(priceFormatter(product.price_currency, -40).replace(/(\u202F|\u00a0)/g, ' '));
269
+
270
+ // Discount date range should be displayed
271
+ screen.getByText('from Jan 01, 2023');
272
+ screen.getByText('to Dec 31, 2023');
273
+ });
274
+
154
275
  it('does not render <CertificateItem /> if product do not have a certificate', async () => {
155
276
  const relation = CourseProductRelationFactory({
156
277
  product: ProductFactory({
@@ -674,7 +795,8 @@ describe('CourseProductItem', () => {
674
795
 
675
796
  it('renders a warning message that tells that no seats are left', async () => {
676
797
  const relation = CourseProductRelationFactory({
677
- order_groups: [OrderGroupFullFactory().one()],
798
+ seats: 2,
799
+ nb_seats_available: 0,
678
800
  }).one();
679
801
  const { product } = relation;
680
802
  const order = CredentialOrderFactory({
@@ -710,86 +832,4 @@ describe('CourseProductItem', () => {
710
832
  expect(screen.queryByRole('button', { name: product.call_to_action })).not.toBeInTheDocument();
711
833
  screen.getByText('Sorry, no seats available for now');
712
834
  });
713
-
714
- it('renders one payment button when one of two order groups is full', async () => {
715
- const relation = CourseProductRelationFactory({
716
- order_groups: [OrderGroupFullFactory().one(), OrderGroupFactory().one()],
717
- }).one();
718
- const { product } = relation;
719
- const order = CredentialOrderFactory({
720
- product_id: product.id,
721
- course: PacedCourseFactory({ code: '00000' }).one(),
722
- target_courses: product.target_courses,
723
- state: OrderState.DRAFT,
724
- }).one();
725
- fetchMock.get(
726
- `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
727
- relation,
728
- );
729
- const orderQueryParameters = {
730
- product_id: order.product_id,
731
- course_code: order.course?.code,
732
- state: NOT_CANCELED_ORDER_STATES,
733
- };
734
- fetchMock.get(
735
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
736
- [order],
737
- );
738
-
739
- render(
740
- <CourseProductItem
741
- productId={product.id}
742
- course={PacedCourseFactory({ code: '00000' }).one()}
743
- />,
744
- );
745
-
746
- // wait for component to be fully loaded
747
- await screen.findByRole('heading', { level: 3, name: product.title });
748
-
749
- expect(screen.queryByText('Sorry, no seats available for now')).not.toBeInTheDocument();
750
- screen.getByRole('button', { name: product.call_to_action });
751
- screen.getByText(relation.order_groups[1].nb_available_seats + ' remaining seats');
752
- });
753
-
754
- it('renders mutliple payment button when there are multiple order groups', async () => {
755
- const relation = CourseProductRelationFactory({
756
- order_groups: [OrderGroupFactory().one(), OrderGroupFactory({ nb_available_seats: 1 }).one()],
757
- }).one();
758
- const { product } = relation;
759
- const order = CredentialOrderFactory({
760
- product_id: product.id,
761
- course: PacedCourseFactory({ code: '00000' }).one(),
762
- target_courses: product.target_courses,
763
- state: OrderState.DRAFT,
764
- }).one();
765
- fetchMock.get(
766
- `https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
767
- relation,
768
- );
769
- const orderQueryParameters = {
770
- product_id: order.product_id,
771
- course_code: order.course?.code,
772
- state: NOT_CANCELED_ORDER_STATES,
773
- };
774
- fetchMock.get(
775
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
776
- [order],
777
- );
778
-
779
- render(
780
- <CourseProductItem
781
- productId={product.id}
782
- course={PacedCourseFactory({ code: '00000' }).one()}
783
- />,
784
- );
785
-
786
- // wait for component to be fully loaded
787
- await screen.findByRole('heading', { level: 3, name: product.title });
788
-
789
- expect(screen.queryByText('Sorry, no seats available for now')).not.toBeInTheDocument();
790
- expect(screen.getAllByTestId('PurchaseButton__cta')).toHaveLength(2);
791
- expect(screen.getAllByRole('button', { name: product.call_to_action })).toHaveLength(2);
792
- screen.getByText(relation.order_groups[0].nb_available_seats + ' remaining seats');
793
- screen.getByText('Last remaining seat!');
794
- });
795
835
  });
@@ -22,7 +22,14 @@ 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({ product: CredentialProductFactory().one() }).one(),
25
+ CourseProductRelationFactory({
26
+ product: CredentialProductFactory({
27
+ price: 840,
28
+ price_currency: 'EUR',
29
+ }).one(),
30
+ discounted_price: 800,
31
+ discount_rate: 0.3,
32
+ }).one(),
26
33
  { overwriteRoutes: true },
27
34
  );
28
35
  fetchMock.get(
@@ -1,7 +1,7 @@
1
1
  import { Children, useEffect, useMemo } from 'react';
2
2
  import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
3
3
  import c from 'classnames';
4
- import { ProductType, Product, CredentialOrder } from 'types/Joanie';
4
+ import { CourseProductRelation, CredentialOrder, Product, ProductType } from 'types/Joanie';
5
5
  import { useCourseProduct } from 'hooks/useCourseProducts';
6
6
  import { Spinner } from 'components/Spinner';
7
7
  import { Icon, IconTypeEnum } from 'components/Icon';
@@ -38,6 +38,31 @@ const messages = defineMessages({
38
38
  description: 'Course run languages',
39
39
  id: 'components.CourseProductItem.availableIn',
40
40
  },
41
+ original_price: {
42
+ defaultMessage: 'Original price:',
43
+ description: 'Label for the original price of a product',
44
+ id: 'components.CourseProductItem.original_price',
45
+ },
46
+ discounted_price: {
47
+ defaultMessage: 'Discounted price:',
48
+ description: 'Label for the discounted price of a product',
49
+ id: 'components.CourseProductItem.discounted_price',
50
+ },
51
+ discount_rate: {
52
+ defaultMessage: '-{rate}%',
53
+ description: 'Discount rate information',
54
+ id: 'components.CourseProductItem.discount_rate',
55
+ },
56
+ from: {
57
+ defaultMessage: 'from {from}',
58
+ description: 'Discount start date information',
59
+ id: 'components.CourseProductItem.from',
60
+ },
61
+ to: {
62
+ defaultMessage: 'to {to}',
63
+ description: 'Discount end date information',
64
+ id: 'components.CourseProductItem.to',
65
+ },
41
66
  });
42
67
 
43
68
  export interface CourseProductItemProps {
@@ -52,8 +77,16 @@ type HeaderProps = {
52
77
  canPurchase: boolean;
53
78
  order: Maybe<CredentialOrder>;
54
79
  product: Product;
80
+ courseProductRelation: CourseProductRelation;
55
81
  };
56
- const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderProps) => {
82
+ const Header = ({
83
+ product,
84
+ order,
85
+ courseProductRelation,
86
+ hasPurchased,
87
+ canPurchase,
88
+ compact,
89
+ }: HeaderProps) => {
57
90
  const intl = useIntl();
58
91
  const formatDate = useDateFormat();
59
92
 
@@ -72,21 +105,90 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
72
105
  return ProductHelper.getLanguages(product, true, intl);
73
106
  }, [canShowMetadata, product, intl]);
74
107
 
75
- return (
76
- <header className="product-widget__header">
77
- <div className="product-widget__header-main">
78
- <h3 className="product-widget__title">{product.title}</h3>
79
- <strong className="product-widget__price h6">
80
- {hasPurchased && <FormattedMessage {...messages.purchased} />}
81
- {canPurchase && (
108
+ const displayPrice = useMemo(() => {
109
+ if (!canPurchase) {
110
+ return null;
111
+ }
112
+
113
+ if (courseProductRelation.discounted_price) {
114
+ return (
115
+ <>
116
+ <span id="original-price" className="offscreen">
117
+ <FormattedMessage {...messages.original_price} />
118
+ </span>
119
+ <del aria-describedby="original-price" className="product-widget__price-discounted">
82
120
  <FormattedNumber
83
121
  currency={product.price_currency}
84
122
  value={product.price}
85
123
  style="currency"
86
124
  />
87
- )}
88
- </strong>
125
+ </del>
126
+ <span id="discount-price" className="offscreen">
127
+ <FormattedMessage {...messages.discounted_price} />
128
+ </span>
129
+ <ins aria-describedby="discount-price" className="product-widget__price-discount">
130
+ <FormattedNumber
131
+ currency={product.price_currency}
132
+ value={courseProductRelation.discounted_price}
133
+ style="currency"
134
+ />
135
+ </ins>
136
+ </>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <FormattedNumber currency={product.price_currency} value={product.price} style="currency" />
142
+ );
143
+ }, [canPurchase, courseProductRelation.discounted_price, product.price]);
144
+
145
+ return (
146
+ <header className="product-widget__header">
147
+ <div className="product-widget__header-main">
148
+ <h3 className="product-widget__title">{product.title}</h3>
89
149
  </div>
150
+ <strong className="product-widget__price h6">
151
+ {hasPurchased && <FormattedMessage {...messages.purchased} />}
152
+ {displayPrice}
153
+ </strong>
154
+ {courseProductRelation?.description && (
155
+ <p className="product-widget__header-description">{courseProductRelation.description}</p>
156
+ )}
157
+ {courseProductRelation?.discounted_price && (
158
+ <p className="product-widget__header-discount">
159
+ {courseProductRelation.discount_rate ? (
160
+ <span className="product-widget__header-discount-rate">
161
+ <FormattedNumber value={-courseProductRelation.discount_rate} style="percent" />
162
+ </span>
163
+ ) : (
164
+ <span className="product-widget__header-discount-amount">
165
+ <FormattedNumber
166
+ currency={product.price_currency}
167
+ value={-courseProductRelation.discount_amount!}
168
+ style="currency"
169
+ />
170
+ </span>
171
+ )}
172
+ {courseProductRelation.discount_start && (
173
+ <span className="product-widget__header-discount-date">
174
+ &nbsp;
175
+ <FormattedMessage
176
+ {...messages.from}
177
+ values={{ from: formatDate(courseProductRelation.discount_start) }}
178
+ />
179
+ </span>
180
+ )}
181
+ {courseProductRelation.discount_end && (
182
+ <span className="product-widget__header-discount-date">
183
+ &nbsp;
184
+ <FormattedMessage
185
+ {...messages.to}
186
+ values={{ to: formatDate(courseProductRelation.discount_end) }}
187
+ />
188
+ </span>
189
+ )}
190
+ </p>
191
+ )}
90
192
  {canShowMetadata && (
91
193
  <>
92
194
  <p
@@ -131,7 +233,7 @@ const Content = ({ product, order }: { product: Product; order?: CredentialOrder
131
233
  <ol className="product-widget__content">
132
234
  {Children.toArray(
133
235
  targetCourses.map((target_course) => (
134
- <CourseRunItem targetCourse={target_course} order={order} />
236
+ <CourseRunItem key={target_course.code} targetCourse={target_course} order={order} />
135
237
  )),
136
238
  )}
137
239
  {product.certificate_definition && (
@@ -179,13 +281,6 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
179
281
  return null;
180
282
  }
181
283
 
182
- const orderGroups = courseProductRelation
183
- ? ProductHelper.getActiveOrderGroups(courseProductRelation)
184
- : [];
185
- const orderGroupsAvailable = orderGroups.filter(
186
- (orderGroup) => orderGroup.nb_available_seats > 0,
187
- );
188
-
189
284
  return (
190
285
  <section
191
286
  className={c('product-widget', {
@@ -214,6 +309,7 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
214
309
  <Header
215
310
  product={product}
216
311
  order={order}
312
+ courseProductRelation={courseProductRelation}
217
313
  canPurchase={canPurchase}
218
314
  hasPurchased={hasPurchased}
219
315
  compact={compact}
@@ -223,8 +319,6 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
223
319
  <CourseProductItemFooter
224
320
  course={course}
225
321
  courseProductRelation={courseProductRelation}
226
- orderGroups={orderGroups}
227
- orderGroupsAvailable={orderGroupsAvailable}
228
322
  canPurchase={canPurchase}
229
323
  />
230
324
  </footer>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.1.2",
3
+ "version": "3.1.3-dev11",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {