richie-education 2.30.1-dev12 → 2.30.1-dev14

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.
@@ -8,6 +8,7 @@ export enum SubscriptionErrorMessageId {
8
8
  ERROR_ADDRESS = 'errorAddress',
9
9
  ERROR_DEFAULT = 'errorDefault',
10
10
  ERROR_FULL_PRODUCT = 'errorFullProduct',
11
+ ERROR_WITHDRAWAL_RIGHT = 'errorWithdrawalRight',
11
12
  }
12
13
 
13
14
  export enum PaymentProviders {
@@ -63,6 +63,8 @@ describe('AddressSelector', () => {
63
63
  unregisterSubmitCallback: jest.fn(),
64
64
  runSubmitCallbacks: jest.fn(),
65
65
  nextStep: jest.fn(),
66
+ hasWaivedWithdrawalRight: false,
67
+ setHasWaivedWithdrawalRight: jest.fn(),
66
68
  }),
67
69
  [billingAddress],
68
70
  );
@@ -35,6 +35,8 @@ export interface SaleTunnelContextType {
35
35
  setBillingAddress: (address?: Address) => void;
36
36
  creditCard?: CreditCard;
37
37
  setCreditCard: (creditCard?: CreditCard) => void;
38
+ hasWaivedWithdrawalRight: boolean;
39
+ setHasWaivedWithdrawalRight: (hasWaivedWithdrawalRight: boolean) => void;
38
40
  registerSubmitCallback: (key: string, callback: () => Promise<void>) => void;
39
41
  unregisterSubmitCallback: (key: string) => void;
40
42
  runSubmitCallbacks: () => Promise<void>;
@@ -76,6 +78,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
76
78
  });
77
79
  const [billingAddress, setBillingAddress] = useState<Address>();
78
80
  const [creditCard, setCreditCard] = useState<CreditCard>();
81
+ const [hasWaivedWithdrawalRight, setHasWaivedWithdrawalRight] = useState(false);
79
82
  const [step, setStep] = useState<SaleTunnelStep>(SaleTunnelStep.IDLE);
80
83
  const [submitCallbacks, setSubmitCallbacks] = useState<Map<string, () => Promise<void>>>(
81
84
  new Map(),
@@ -115,6 +118,8 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
115
118
  setBillingAddress,
116
119
  creditCard,
117
120
  setCreditCard,
121
+ hasWaivedWithdrawalRight,
122
+ setHasWaivedWithdrawalRight,
118
123
  nextStep,
119
124
  step,
120
125
  registerSubmitCallback: (key, callback) => {
@@ -131,7 +136,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
131
136
  await Promise.all(Array.from(submitCallbacks.values()).map((cb) => cb()));
132
137
  },
133
138
  }),
134
- [props, order, billingAddress, creditCard, step, submitCallbacks],
139
+ [props, order, billingAddress, creditCard, step, submitCallbacks, hasWaivedWithdrawalRight],
135
140
  );
136
141
 
137
142
  return (
@@ -7,6 +7,7 @@ import { useSession } from 'contexts/SessionContext';
7
7
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
8
8
  import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
9
9
  import { Spinner } from 'components/Spinner';
10
+ import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
10
11
 
11
12
  const messages = defineMessages({
12
13
  title: {
@@ -71,6 +72,7 @@ export const SaleTunnelInformation = () => {
71
72
  <div>
72
73
  <PaymentScheduleBlock />
73
74
  <Total />
75
+ <WithdrawRightCheckbox />
74
76
  </div>
75
77
  </div>
76
78
  );
@@ -4,4 +4,12 @@
4
4
  margin-top: 0.5rem;
5
5
  margin-bottom: 0;
6
6
  }
7
+
8
+ &__waiveCheckbox {
9
+ & > .waiveCheckbox__input {
10
+ /* Just add 1 px offset to prevent border input to be hidden
11
+ due to the overflow hidden applied to the parent block */
12
+ margin-left: 1px;
13
+ }
14
+ }
7
15
  }
@@ -1,4 +1,4 @@
1
- import { useMemo, useEffect, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { Alert, Button, VariantType } from '@openfun/cunningham-react';
3
3
  import { defineMessages, FormattedMessage } from 'react-intl';
4
4
  import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
@@ -50,6 +50,11 @@ const messages = defineMessages({
50
50
  description: "Error message shown when the user didn't select a billing address.",
51
51
  id: 'components.SubscriptionButton.errorAddress',
52
52
  },
53
+ errorWithdrawalRight: {
54
+ defaultMessage: 'You must waive your withdrawal right.',
55
+ description: "Error message shown when the user must waive its withdrawal right but doesn't.",
56
+ id: 'components.SubscriptionButton.errorWithdrawalRight',
57
+ },
53
58
  orderCreationInProgress: {
54
59
  defaultMessage: 'Order creation in progress',
55
60
  description: 'Label for screen reader when an order creation is in progress.',
@@ -65,7 +70,10 @@ enum ComponentStates {
65
70
 
66
71
  interface Props {
67
72
  buildOrderPayload: (
68
- payload: Pick<OrderCreationPayload, 'product_id' | 'billing_address' | 'order_group_id'>,
73
+ payload: Pick<
74
+ OrderCreationPayload,
75
+ 'product_id' | 'billing_address' | 'order_group_id' | 'has_waived_withdrawal_right'
76
+ >,
69
77
  ) => OrderCreationPayload;
70
78
  }
71
79
 
@@ -74,6 +82,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
74
82
  order,
75
83
  creditCard,
76
84
  billingAddress,
85
+ hasWaivedWithdrawalRight,
77
86
  product,
78
87
  nextStep,
79
88
  runSubmitCallbacks,
@@ -107,10 +116,16 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
107
116
  return;
108
117
  }
109
118
 
119
+ if (!product.is_withdrawable && !hasWaivedWithdrawalRight) {
120
+ handleError(SubscriptionErrorMessageId.ERROR_WITHDRAWAL_RIGHT);
121
+ return;
122
+ }
123
+
110
124
  const payload = buildOrderPayload({
111
125
  product_id: product.id,
112
126
  billing_address: billingAddress!,
113
127
  order_group_id: saleTunnelProps.orderGroup?.id,
128
+ has_waived_withdrawal_right: hasWaivedWithdrawalRight,
114
129
  });
115
130
 
116
131
  orderMethods.create(payload, {
@@ -0,0 +1,59 @@
1
+ import { Alert, Checkbox, VariantType } from '@openfun/cunningham-react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { defineMessages, FormattedMessage } from 'react-intl/lib';
4
+ import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
5
+
6
+ const messages = defineMessages({
7
+ waiveCheckboxExplanation: {
8
+ defaultMessage:
9
+ 'This training will start before the end of your withdrawal period. You must waive it to subscribe.',
10
+ description: 'Text to explain why the user has to waive to its withdrawal right.',
11
+ id: 'components.SaleTunnel.WithdrawRightCheckbox.waiverLabel',
12
+ },
13
+ waiveCheckboxLabel: {
14
+ defaultMessage: 'I waive my right of withdrawal',
15
+ description: 'Label of the checkbox to waive the withdrawal right.',
16
+ id: 'components.SaleTunnel.WithdrawRightCheckbox.waiveCheckboxLabel',
17
+ },
18
+ });
19
+
20
+ const WithdrawRightCheckbox = () => {
21
+ const {
22
+ product,
23
+ registerSubmitCallback,
24
+ unregisterSubmitCallback,
25
+ hasWaivedWithdrawalRight,
26
+ setHasWaivedWithdrawalRight,
27
+ } = useSaleTunnelContext();
28
+ const [hasErrorState, setHasError] = useState(false);
29
+ const setError = useCallback(async () => {
30
+ setHasError(!product.is_withdrawable && !hasWaivedWithdrawalRight);
31
+ }, [hasWaivedWithdrawalRight, product.is_withdrawable]);
32
+
33
+ useEffect(() => {
34
+ registerSubmitCallback('withdrawalRight', setError);
35
+ return () => {
36
+ unregisterSubmitCallback('withdrawalRight');
37
+ };
38
+ }, [setError]);
39
+
40
+ if (product.is_withdrawable) return null;
41
+ return (
42
+ <section
43
+ className="mt-t subscription-button__waiveCheckbox"
44
+ data-testid="withdraw-right-checkbox"
45
+ >
46
+ <Alert type={hasErrorState ? VariantType.ERROR : VariantType.WARNING} className="mb-s">
47
+ <FormattedMessage {...messages.waiveCheckboxExplanation} />
48
+ </Alert>
49
+ <Checkbox
50
+ className="waiveCheckbox__input"
51
+ label={<FormattedMessage {...messages.waiveCheckboxLabel} />}
52
+ checked={hasWaivedWithdrawalRight}
53
+ onChange={(e) => setHasWaivedWithdrawalRight(e.target.checked)}
54
+ />
55
+ </section>
56
+ );
57
+ };
58
+
59
+ export default WithdrawRightCheckbox;
@@ -20,6 +20,7 @@ import {
20
20
  CreditCardFactory,
21
21
  PaymentFactory,
22
22
  PaymentInstallmentFactory,
23
+ ProductFactory,
23
24
  } from 'utils/test/factories/joanie';
24
25
  import { CourseRun, NOT_CANCELED_ORDER_STATES, OrderState } from 'types/Joanie';
25
26
  import { Priority } from 'types';
@@ -97,9 +98,9 @@ describe('SaleTunnel', () => {
97
98
  * Initialization.
98
99
  */
99
100
  const course = PacedCourseFactory().one();
100
- const relation = CourseProductRelationFactory({ course }).one();
101
+ const product = ProductFactory({ is_withdrawable: false }).one();
102
+ const relation = CourseProductRelationFactory({ course, product }).one();
101
103
  const paymentSchedule = PaymentInstallmentFactory().many(2);
102
- const { product } = relation;
103
104
 
104
105
  fetchMock.get(
105
106
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
@@ -265,6 +266,11 @@ describe('SaleTunnel', () => {
265
266
  priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
266
267
  );
267
268
 
269
+ /**
270
+ * Make sure the checkbox to waive withdrawal right is displayed
271
+ */
272
+ const $waiveCheckbox = screen.getByLabelText('I waive my right of withdrawal');
273
+
268
274
  /**
269
275
  * Subscribe
270
276
  */
@@ -282,6 +288,14 @@ describe('SaleTunnel', () => {
282
288
  }) as HTMLButtonElement;
283
289
  await user.click($button);
284
290
 
291
+ /**
292
+ * An error should be displayed if the user has not waived its withdrawal right.
293
+ */
294
+ screen.getByText('You must waive your withdrawal right.');
295
+
296
+ await user.click($waiveCheckbox);
297
+ await user.click($button);
298
+
285
299
  order.state = OrderState.TO_SAVE_PAYMENT_METHOD;
286
300
  order.contract = ContractFactory({ student_signed_on: new Date().toISOString() }).one();
287
301
 
@@ -453,4 +453,44 @@ describe.each([
453
453
 
454
454
  screen.getByTestId('walkthrough-banner');
455
455
  });
456
+
457
+ it('should show a checkbox to waive withdrawal right if the product is not withdrawable', async () => {
458
+ const product = ProductFactory({ is_withdrawable: false }).one();
459
+ const schedule = PaymentInstallmentFactory().many(2);
460
+ fetchMock
461
+ .get(
462
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
463
+ [],
464
+ )
465
+ .get(
466
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
467
+ schedule,
468
+ );
469
+
470
+ render(<Wrapper product={product} />, {
471
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
472
+ });
473
+
474
+ screen.getByTestId('withdraw-right-checkbox');
475
+ });
476
+
477
+ it('should not show a checkbox to waive withdrawal right if the product is withdrawable', async () => {
478
+ const product = ProductFactory({ is_withdrawable: true }).one();
479
+ const schedule = PaymentInstallmentFactory().many(2);
480
+ fetchMock
481
+ .get(
482
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
483
+ [],
484
+ )
485
+ .get(
486
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
487
+ schedule,
488
+ );
489
+
490
+ render(<Wrapper product={product} />, {
491
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
492
+ });
493
+
494
+ expect(screen.queryByTestId('withdraw-right-checkbox')).toBeNull();
495
+ });
456
496
  });
@@ -149,6 +149,7 @@ export interface Product {
149
149
  state: CourseState;
150
150
  instructions: Nullable<string>;
151
151
  contract_definition?: ContractDefinition;
152
+ is_withdrawable: boolean;
152
153
  }
153
154
 
154
155
  export interface CredentialProduct extends Product {
@@ -468,6 +469,7 @@ interface AbstractOrderProductCreationPayload {
468
469
  product_id: Product['id'];
469
470
  order_group_id?: OrderGroup['id'];
470
471
  billing_address: Omit<Address, 'id' | 'is_main'>;
472
+ has_waived_withdrawal_right: boolean;
471
473
  }
472
474
 
473
475
  interface OrderCertificateCreationPayload extends AbstractOrderProductCreationPayload {
@@ -206,6 +206,7 @@ export const CredentialProductFactory = factory((): CredentialProduct => {
206
206
  remaining_order_count: faker.number.int({ min: 1, max: 100 }),
207
207
  state: CourseStateFactory().one(),
208
208
  instructions: null,
209
+ is_withdrawable: true,
209
210
  };
210
211
  });
211
212
 
@@ -490,6 +491,8 @@ export const SaleTunnelContextFactory = factory(
490
491
  billingAddress: undefined,
491
492
  setBillingAddress: noop,
492
493
  setCreditCard: noop,
494
+ setHasWaivedWithdrawalRight: noop,
495
+ hasWaivedWithdrawalRight: false,
493
496
  step: SaleTunnelStep.IDLE,
494
497
  registerSubmitCallback: noop,
495
498
  unregisterSubmitCallback: noop,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.30.1-dev12",
3
+ "version": "2.30.1-dev14",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {