richie-education 2.28.2-dev39 → 2.28.2-dev53

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 (82) hide show
  1. package/js/api/joanie.ts +12 -16
  2. package/js/api/lms/dummy.ts +1 -12
  3. package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +16 -9
  4. package/js/components/ContractFrame/AbstractContractFrame.tsx +28 -23
  5. package/js/components/ContractFrame/LearnerContractFrame.tsx +2 -2
  6. package/js/components/ContractFrame/_styles.scss +6 -14
  7. package/js/components/CreditCardSelector/index.spec.tsx +7 -7
  8. package/js/components/CreditCardSelector/index.tsx +2 -2
  9. package/js/components/DownloadContractButton/index.spec.tsx +1 -1
  10. package/js/components/OpenEdxFullNameForm/index.spec.tsx +229 -0
  11. package/js/components/OpenEdxFullNameForm/index.tsx +7 -7
  12. package/js/components/PaymentInterfaces/LyraPopIn.tsx +2 -2
  13. package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
  14. package/js/components/PaymentInterfaces/__mocks__/index.tsx +1 -4
  15. package/js/components/PaymentInterfaces/types.ts +5 -2
  16. package/js/components/PurchaseButton/index.spec.tsx +69 -37
  17. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +2 -1
  18. package/js/components/SaleTunnel/CertificateSaleTunnel/index.tsx +2 -2
  19. package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +6 -10
  20. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +75 -41
  21. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +0 -30
  22. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/_styles.scss +12 -0
  23. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +160 -0
  24. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +15 -29
  25. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +5 -0
  26. package/js/components/SaleTunnel/SubscriptionButton/_styles.scss +7 -0
  27. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +201 -0
  28. package/js/components/SaleTunnel/_styles.scss +10 -1
  29. package/js/components/SaleTunnel/hooks/useTerms.tsx +0 -77
  30. package/js/components/SaleTunnel/index.credential.spec.tsx +12 -21
  31. package/js/components/SaleTunnel/index.full-process.spec.tsx +110 -48
  32. package/js/components/SaleTunnel/index.spec.tsx +330 -779
  33. package/js/components/SignContractButton/index.omniscientOrders.spec.tsx +16 -11
  34. package/js/components/SignContractButton/index.spec.tsx +16 -20
  35. package/js/components/SignContractButton/index.tsx +3 -1
  36. package/js/hooks/useCreditCards/index.spec.tsx +70 -6
  37. package/js/hooks/useCreditCards/index.ts +49 -11
  38. package/js/hooks/useOrders/index.spec.tsx +322 -0
  39. package/js/hooks/{useOrders.ts → useOrders/index.ts} +40 -14
  40. package/js/hooks/useProductOrder/index.spec.tsx +77 -60
  41. package/js/hooks/useProductOrder/index.tsx +2 -2
  42. package/js/hooks/useResources/useResourcesRoot.ts +1 -0
  43. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.spec.tsx +1 -1
  44. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.tsx +4 -2
  45. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +8 -5
  46. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +8 -9
  47. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +1 -1
  48. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +1 -6
  49. package/js/settings/settings.test.ts +11 -2
  50. package/js/types/Joanie.ts +49 -34
  51. package/js/utils/OrderHelper/index.ts +38 -42
  52. package/js/utils/test/factories/joanie.ts +36 -51
  53. package/js/widgets/Dashboard/components/DashboardItem/CourseEnrolling/index.tsx +8 -18
  54. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +26 -32
  55. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +11 -6
  56. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +7 -6
  57. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +9 -10
  58. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +3 -1
  59. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +6 -7
  60. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +28 -8
  61. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +2 -5
  62. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +18 -71
  63. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +34 -35
  64. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +27 -24
  65. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +18 -73
  66. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +32 -16
  67. package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +3 -11
  68. package/js/widgets/Dashboard/components/Signature/SignatureDummy.tsx +25 -3
  69. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/EnrollableCourseRunList.tsx +2 -6
  70. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +7 -14
  71. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.spec.tsx +7 -5
  72. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.tsx +5 -7
  73. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +242 -332
  74. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +12 -13
  75. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +10 -21
  76. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +2 -2
  77. package/package.json +1 -1
  78. package/scss/components/_index.scss +2 -1
  79. package/js/components/PaymentButton/_styles.scss +0 -27
  80. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +0 -333
  81. package/js/components/SaleTunnel/SaleTunnelNotValidated/index.tsx +0 -70
  82. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader/index.tsx +0 -41
@@ -0,0 +1,201 @@
1
+ import { useMemo, useEffect, useState } from 'react';
2
+ import { Alert, Button, VariantType } from '@openfun/cunningham-react';
3
+ import { defineMessages, FormattedMessage } from 'react-intl';
4
+ import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
5
+ import { useOrders } from 'hooks/useOrders';
6
+ import { OrderCreationPayload } from 'types/Joanie';
7
+ import { useMatchMediaLg } from 'hooks/useMatchMedia';
8
+ import { SubscriptionErrorMessageId } from 'components/PaymentInterfaces/types';
9
+ import { HttpError } from 'utils/errors/HttpError';
10
+ import { Spinner } from 'components/Spinner';
11
+
12
+ const messages = defineMessages({
13
+ subscribe: {
14
+ id: 'components.SaleTunnel.SubscriptionButton.subscribe',
15
+ defaultMessage: 'Subscribe',
16
+ description: 'Label of the button to subscribe to a product.',
17
+ },
18
+ walkthroughToSignAndSavePayment: {
19
+ id: 'components.SaleTunnel.SubscriptionButton.walkthroughToSignAndSavePayment',
20
+ defaultMessage:
21
+ 'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method.',
22
+ description:
23
+ 'Message explaining the subscription process with a training agreement to sign and a payment method to set.',
24
+ },
25
+ walkthroughToSign: {
26
+ id: 'components.SaleTunnel.SubscriptionButton.walkthroughToSign',
27
+ defaultMessage:
28
+ 'To enroll in the training, you will be invited to sign the training agreement.',
29
+ description:
30
+ 'Message explaining the subscription process with only a training agreement to sign.',
31
+ },
32
+ walkthroughToSavePayment: {
33
+ id: 'components.SaleTunnel.SubscriptionButton.walkthroughToSavePayment',
34
+ defaultMessage: 'To enroll in the training, you will be invited to define a payment method.',
35
+ description: 'Message explaining the subscription process with only a payment method to set.',
36
+ },
37
+ errorDefault: {
38
+ defaultMessage: 'An error occurred during order creation. Please retry later.',
39
+ description: 'Error message shown when order creation request failed.',
40
+ id: 'components.SubscriptionButton.errorDefault',
41
+ },
42
+ errorFullProduct: {
43
+ defaultMessage: 'There are no more places available for this product.',
44
+ description:
45
+ 'Error message shown when order creation request failed because there is no remaining available seat for the product.',
46
+ id: 'components.SubscriptionButton.errorFullProduct',
47
+ },
48
+ errorAddress: {
49
+ defaultMessage: 'You must have a billing address.',
50
+ description: "Error message shown when the user didn't select a billing address.",
51
+ id: 'components.SubscriptionButton.errorAddress',
52
+ },
53
+ orderCreationInProgress: {
54
+ defaultMessage: 'Order creation in progress',
55
+ description: 'Label for screen reader when an order creation is in progress.',
56
+ id: 'components.SubscriptionButton.orderCreationInProgress',
57
+ },
58
+ });
59
+
60
+ enum ComponentStates {
61
+ IDLE = 'idle',
62
+ LOADING = 'loading',
63
+ ERROR = 'error',
64
+ }
65
+
66
+ interface Props {
67
+ buildOrderPayload: (
68
+ payload: Pick<OrderCreationPayload, 'product_id' | 'billing_address' | 'order_group_id'>,
69
+ ) => OrderCreationPayload;
70
+ }
71
+
72
+ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
73
+ const {
74
+ order,
75
+ creditCard,
76
+ billingAddress,
77
+ product,
78
+ nextStep,
79
+ runSubmitCallbacks,
80
+ props: saleTunnelProps,
81
+ } = useSaleTunnelContext();
82
+ const { methods: orderMethods } = useOrders(undefined, { enabled: false });
83
+ const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
84
+ const [error, setError] = useState<SubscriptionErrorMessageId | string>();
85
+ const isMobile = useMatchMediaLg();
86
+
87
+ const handleError = (
88
+ messageId: SubscriptionErrorMessageId | string = SubscriptionErrorMessageId.ERROR_DEFAULT,
89
+ ) => {
90
+ setState(ComponentStates.ERROR);
91
+ setError(messageId);
92
+ };
93
+
94
+ const createOrder = async () => {
95
+ setState(ComponentStates.LOADING);
96
+
97
+ try {
98
+ await runSubmitCallbacks();
99
+ } catch (e) {
100
+ setState(ComponentStates.IDLE);
101
+ return;
102
+ }
103
+
104
+ if (!billingAddress) {
105
+ handleError(SubscriptionErrorMessageId.ERROR_ADDRESS);
106
+ return;
107
+ }
108
+
109
+ const payload = buildOrderPayload({
110
+ product_id: product.id,
111
+ billing_address: billingAddress!,
112
+ order_group_id: saleTunnelProps.orderGroup?.id,
113
+ });
114
+
115
+ orderMethods.create(payload, {
116
+ onError: async (createOrderError: HttpError) => {
117
+ if (createOrderError.responseBody) {
118
+ const responseErrors = await createOrderError.responseBody;
119
+ if ('max_validated_orders' in responseErrors) {
120
+ handleError(SubscriptionErrorMessageId.ERROR_FULL_PRODUCT);
121
+ }
122
+ }
123
+ handleError();
124
+ },
125
+ });
126
+ };
127
+
128
+ const walkthroughMessages = useMemo(() => {
129
+ if (product.contract_definition && product.price > 0) {
130
+ return messages.walkthroughToSignAndSavePayment;
131
+ } else if (product.contract_definition && product.price === 0) {
132
+ return messages.walkthroughToSign;
133
+ } else if (!product.contract_definition && product.price > 0) {
134
+ return messages.walkthroughToSavePayment;
135
+ }
136
+ }, [product, creditCard]);
137
+
138
+ useEffect(() => {
139
+ if (order) nextStep();
140
+ }, [order]);
141
+
142
+ useEffect(() => {
143
+ if (error && [ComponentStates.IDLE, ComponentStates.LOADING].includes(state)) {
144
+ setError(undefined);
145
+ }
146
+ if (state === ComponentStates.ERROR) {
147
+ document.querySelector<HTMLElement>('#sale-tunnel-subscription-error')?.focus();
148
+ }
149
+ }, [state]);
150
+
151
+ return (
152
+ <>
153
+ <div style={{ maxWidth: '680px' }} className="mb-s" data-testid="walkthrough-banner">
154
+ {walkthroughMessages && (
155
+ <Alert type={VariantType.INFO}>
156
+ <FormattedMessage
157
+ {...walkthroughMessages}
158
+ values={{ credictCarNumbers: creditCard?.last_numbers }}
159
+ />
160
+ </Alert>
161
+ )}
162
+ </div>
163
+ <Button
164
+ onClick={createOrder}
165
+ fullWidth={isMobile}
166
+ disabled={state === ComponentStates.LOADING}
167
+ {...(state === ComponentStates.ERROR && {
168
+ 'aria-describedby': 'sale-tunnel-payment-error',
169
+ })}
170
+ >
171
+ {state === ComponentStates.LOADING ? (
172
+ <Spinner theme="light" aria-labelledby="order-creation-in-progress">
173
+ <span id="order-creation-in-progress">
174
+ <FormattedMessage {...messages.orderCreationInProgress} />
175
+ </span>
176
+ </Spinner>
177
+ ) : (
178
+ <FormattedMessage {...messages.subscribe} />
179
+ )}
180
+ </Button>
181
+ {state === ComponentStates.ERROR && (
182
+ <p className="subscription-button__error" id="sale-tunnel-subscription-error" tabIndex={-1}>
183
+ {!error || messages.hasOwnProperty(error) ? (
184
+ <FormattedMessage
185
+ {...messages[
186
+ (error as Exclude<
187
+ SubscriptionErrorMessageId,
188
+ SubscriptionErrorMessageId.ERROR_ABORT
189
+ >) || SubscriptionErrorMessageId.ERROR_DEFAULT
190
+ ]}
191
+ />
192
+ ) : (
193
+ error
194
+ )}
195
+ </p>
196
+ )}
197
+ </>
198
+ );
199
+ };
200
+
201
+ export default SubscriptionButton;
@@ -105,14 +105,23 @@
105
105
  text-align: left;
106
106
  }
107
107
 
108
- .sale-tunnel-end {
108
+ .sale-tunnel-step {
109
109
  align-items: center;
110
110
  display: flex;
111
111
  flex-direction: column;
112
112
 
113
113
  &__header {
114
+ align-items: center;
115
+ display: flex;
116
+ flex-direction: column;
114
117
  margin-bottom: 0;
115
118
  text-align: center;
119
+
120
+ & > .icon {
121
+ --size: 4.5rem;
122
+ height: var(--size);
123
+ width: var(--size);
124
+ }
116
125
  }
117
126
 
118
127
  &__title {
@@ -1,77 +0,0 @@
1
- import { Checkbox } from '@openfun/cunningham-react';
2
- import { useState } from 'react';
3
- import { defineMessages, useIntl } from 'react-intl';
4
- import { Product } from 'types/Joanie';
5
- import context from 'utils/context';
6
- import { PaymentErrorMessageId } from 'components/PaymentInterfaces/types';
7
-
8
- const messages = defineMessages({
9
- termsMessage: {
10
- defaultMessage: 'By checking this box, you accept the ',
11
- description: 'Message next to the checkbox in order to accept the terms',
12
- id: 'components.PaymentButton.termsMessage',
13
- },
14
- termsMessageLink: {
15
- defaultMessage: 'General Terms of Sale',
16
- description: 'Clickable link included in the terms message',
17
- id: 'components.SaleTunnelStepPayment.termsMessageLink',
18
- },
19
- termsMessageLinkTitle: {
20
- defaultMessage: 'Open a preview of the General Terms of Sale',
21
- description: 'Title of clickable link included in the terms message',
22
- id: 'components.SaleTunnelStepPayment.termsMessageLinkTitle',
23
- },
24
- });
25
-
26
- export const useTerms = ({
27
- product,
28
- onError,
29
- error,
30
- }: {
31
- product: Product;
32
- onError: (error: PaymentErrorMessageId | string) => void;
33
- error?: PaymentErrorMessageId | string;
34
- }) => {
35
- const intl = useIntl();
36
- const [termsAccepted, setTermsAccepted] = useState(false);
37
- const validateTerms = () => {
38
- if (!product.contract_definition) {
39
- return;
40
- }
41
- if (!termsAccepted) {
42
- onError(PaymentErrorMessageId.ERROR_TERMS);
43
- }
44
- };
45
-
46
- return {
47
- termsAccepted: termsAccepted || !product.contract_definition,
48
- validateTerms,
49
- renderTermsCheckbox: () => {
50
- if (!product.contract_definition) {
51
- return null;
52
- }
53
- return (
54
- <section className="payment-button__terms">
55
- <Checkbox
56
- label={
57
- <>
58
- {intl.formatMessage(messages.termsMessage)}{' '}
59
- <a
60
- href={context.site_urls.terms_and_conditions ?? '#'}
61
- target="_blank"
62
- rel="noopener noreferrer"
63
- title={intl.formatMessage(messages.termsMessageLinkTitle)}
64
- >
65
- {intl.formatMessage(messages.termsMessageLink)}
66
- </a>
67
- </>
68
- }
69
- onChange={(e) => setTermsAccepted(e.target.checked)}
70
- checked={termsAccepted}
71
- state={error === PaymentErrorMessageId.ERROR_TERMS ? 'error' : 'default'}
72
- />
73
- </section>
74
- );
75
- },
76
- };
77
- };
@@ -1,5 +1,6 @@
1
1
  import fetchMock from 'fetch-mock';
2
2
  import { act, fireEvent, screen, waitFor } from '@testing-library/react';
3
+ import queryString from 'query-string';
3
4
  import {
4
5
  RichieContextFactory as mockRichieContextFactory,
5
6
  PacedCourseFactory,
@@ -8,13 +9,13 @@ import {
8
9
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
9
10
  import {
10
11
  AddressFactory,
11
- CredentialOrderWithPaymentFactory,
12
+ CredentialOrderFactory,
12
13
  CredentialProductFactory,
13
14
  OrderGroupFactory,
14
15
  } from 'utils/test/factories/joanie';
15
16
  import type * as Joanie from 'types/Joanie';
16
17
  import { Maybe } from 'types/utils';
17
- import { OrderCredentialCreationPayload } from 'types/Joanie';
18
+ import { NOT_CANCELED_ORDER_STATES, OrderCredentialCreationPayload } from 'types/Joanie';
18
19
  import { SaleTunnel, SaleTunnelProps } from 'components/SaleTunnel/index';
19
20
  import { render } from 'utils/test/render';
20
21
  import { getAddressLabel } from 'components/SaleTunnel/AddressSelector';
@@ -86,26 +87,23 @@ describe('SaleTunnel / Credential', () => {
86
87
  const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
87
88
 
88
89
  let createOrderPayload: Maybe<OrderCredentialCreationPayload>;
89
- const { payment_info: paymentInfo, ...order } = CredentialOrderWithPaymentFactory().one();
90
+ const order = CredentialOrderFactory({ order_group_id: orderGroup.id }).one();
91
+ const orderQueryParameters = {
92
+ course_code: course.code,
93
+ product_id: product.id,
94
+ state: NOT_CANCELED_ORDER_STATES,
95
+ };
96
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`;
90
97
  fetchMock
91
- .get(
92
- `https://joanie.endpoint/api/v1.0/orders/?course_code=${course.code}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
93
- [],
94
- )
98
+ .get(url, [])
95
99
  .get(
96
100
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
97
101
  [],
98
102
  )
99
- .post('https://joanie.endpoint/api/v1.0/orders/', (url, { body }) => {
103
+ .post('https://joanie.endpoint/api/v1.0/orders/', (_, { body }) => {
100
104
  createOrderPayload = JSON.parse(body as any);
101
105
  return order;
102
106
  })
103
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
104
- paymentInfo,
105
- })
106
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, {
107
- ...order,
108
- })
109
107
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
110
108
  overwriteRoutes: true,
111
109
  });
@@ -121,13 +119,6 @@ describe('SaleTunnel / Credential', () => {
121
119
  name: `Subscribe`,
122
120
  }) as HTMLButtonElement;
123
121
 
124
- const $terms = screen.getByLabelText(
125
- 'By checking this box, you accept the General Terms of Sale',
126
- );
127
- await act(async () => {
128
- fireEvent.click($terms);
129
- });
130
-
131
122
  // - Payment button should not be disabled.
132
123
  expect($button.disabled).toBe(false);
133
124
 
@@ -1,8 +1,9 @@
1
- import { screen, within } from '@testing-library/react';
1
+ import { act, screen, within } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
  import queryString from 'query-string';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import countries from 'i18n-iso-countries';
6
+ import { getAllByRole } from '@testing-library/dom';
6
7
  import {
7
8
  RichieContextFactory as mockRichieContextFactory,
8
9
  PacedCourseFactory,
@@ -13,11 +14,14 @@ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
13
14
  import CourseProductItem from 'widgets/SyllabusCourseRunsList/components/CourseProductItem';
14
15
  import {
15
16
  AddressFactory,
16
- CredentialOrderWithPaymentFactory,
17
- PaymentInstallmentFactory,
17
+ ContractFactory,
18
18
  CourseProductRelationFactory,
19
+ CredentialOrderFactory,
20
+ CreditCardFactory,
21
+ PaymentFactory,
22
+ PaymentInstallmentFactory,
19
23
  } from 'utils/test/factories/joanie';
20
- import { ACTIVE_ORDER_STATES, CourseRun } from 'types/Joanie';
24
+ import { CourseRun, NOT_CANCELED_ORDER_STATES, OrderState } from 'types/Joanie';
21
25
  import { Priority } from 'types';
22
26
  import { expectMenuToBeClosed, expectMenuToBeOpen } from 'utils/test/Cunningham';
23
27
  import { changeSelect } from 'components/Form/test-utils';
@@ -26,6 +30,7 @@ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
26
30
  import { User } from 'types/User';
27
31
  import { OpenEdxApiProfile } from 'types/openEdx';
28
32
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
33
+ import { Deferred } from 'utils/test/deferred';
29
34
 
30
35
  jest.mock('utils/context', () => ({
31
36
  __esModule: true,
@@ -54,6 +59,12 @@ describe('SaleTunnel', () => {
54
59
  let openApiEdxProfile: OpenEdxApiProfile;
55
60
  setupJoanieSession();
56
61
 
62
+ const dateFormatter = Intl.DateTimeFormat('en', {
63
+ day: '2-digit',
64
+ month: 'short',
65
+ year: 'numeric',
66
+ });
67
+
57
68
  const priceFormatter = (currency: string, price: number) =>
58
69
  new Intl.NumberFormat('en', {
59
70
  currency,
@@ -81,13 +92,14 @@ describe('SaleTunnel', () => {
81
92
  fetchMock.get(`https://auth.test/api/v1.0/user/me`, richieUser);
82
93
  });
83
94
 
84
- it('tests the entire process of buying a credential product', async () => {
95
+ it('tests the entire process of subscribing to a credential product', async () => {
85
96
  /**
86
97
  * Initialization.
87
98
  */
88
- const relation = CourseProductRelationFactory().one();
99
+ const course = PacedCourseFactory().one();
100
+ const relation = CourseProductRelationFactory({ course }).one();
89
101
  const paymentSchedule = PaymentInstallmentFactory().many(2);
90
- const { product, course } = relation;
102
+ const { product } = relation;
91
103
 
92
104
  fetchMock.get(
93
105
  `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
@@ -101,22 +113,16 @@ describe('SaleTunnel', () => {
101
113
  const orderQueryParameters = {
102
114
  product_id: product.id,
103
115
  course_code: course.code,
104
- state: ACTIVE_ORDER_STATES,
116
+ state: NOT_CANCELED_ORDER_STATES,
105
117
  };
106
118
  fetchMock.get(
107
119
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
108
120
  [],
109
121
  );
110
122
 
111
- render(
112
- <CourseProductItem
113
- productId={product.id}
114
- course={PacedCourseFactory({ id: course.id, code: course.code }).one()}
115
- />,
116
- {
117
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
118
- },
119
- );
123
+ render(<CourseProductItem productId={product.id} course={course} />, {
124
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
125
+ });
120
126
 
121
127
  // Wait for product information to be fetched
122
128
  await screen.findByRole('heading', { level: 3, name: product.title });
@@ -216,10 +222,11 @@ describe('SaleTunnel', () => {
216
222
 
217
223
  // - User fulfills address fields
218
224
  const address = AddressFactory({ is_main: true }).one();
219
- fetchMock.post('https://joanie.endpoint/api/v1.0/addresses/', address);
220
- fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', [address], {
221
- overwriteRoutes: true,
222
- });
225
+ fetchMock
226
+ .post('https://joanie.endpoint/api/v1.0/addresses/', address)
227
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [address], {
228
+ overwriteRoutes: true,
229
+ });
223
230
 
224
231
  await user.type($titleField, address.title);
225
232
  await user.type($firstnameField, address.first_name);
@@ -235,16 +242,23 @@ describe('SaleTunnel', () => {
235
242
  ).toBeInTheDocument();
236
243
 
237
244
  /**
238
- * Make sure no credit card is selected.
245
+ * Make sure the payment schedule is displayed.
239
246
  */
240
- screen.getByRole('heading', {
241
- name: 'Payment method',
247
+ screen.getByRole('heading', { name: 'Payment schedule' });
248
+ paymentSchedule.forEach((installment, index) => {
249
+ const row = screen.getByTestId(installment.id);
250
+ const cells = getAllByRole(row, 'cell');
251
+ expect(cells).toHaveLength(4);
252
+ expect(cells[0]).toHaveTextContent((index + 1).toString());
253
+ expect(cells[1]).toHaveTextContent(
254
+ priceFormatter(installment.currency, installment.amount).replace(/(\u202F|\u00a0)/g, ' '),
255
+ );
256
+ expect(cells[2]).toHaveTextContent(
257
+ `Withdrawn on ${dateFormatter.format(new Date(installment.due_date))}`,
258
+ );
259
+ expect(cells[3]).toHaveTextContent(new RegExp(installment.state, 'i'));
242
260
  });
243
- screen.getByText('Use another credit card during payment');
244
261
 
245
- /**
246
- * Make sure the total is the correct one.
247
- */
248
262
  const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
249
263
  expect($totalAmount).toHaveTextContent(
250
264
  'Total' +
@@ -252,34 +266,84 @@ describe('SaleTunnel', () => {
252
266
  );
253
267
 
254
268
  /**
255
- * Pay
269
+ * Subscribe
256
270
  */
257
- const $terms = screen.getByLabelText(
258
- 'By checking this box, you accept the General Terms of Sale',
259
- );
260
- await user.click($terms);
261
-
262
- const { payment_info: paymentInfo, ...order } = CredentialOrderWithPaymentFactory().one();
271
+ const order = CredentialOrderFactory({ state: OrderState.TO_SIGN }).one();
263
272
  fetchMock
273
+ .post('https://joanie.endpoint/api/v1.0/orders/', order)
264
274
  .get(
265
275
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
266
276
  [order],
267
277
  { overwriteRoutes: true },
268
- )
269
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
270
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
271
- paymentInfo,
272
- })
273
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, {
274
- ...order,
275
- });
278
+ );
276
279
 
277
280
  const $button = screen.getByRole('button', {
278
281
  name: `Subscribe`,
279
282
  }) as HTMLButtonElement;
280
283
  await user.click($button);
281
284
 
282
- await screen.findByText('Payment in progress');
285
+ order.state = OrderState.TO_SAVE_PAYMENT_METHOD;
286
+ order.contract = ContractFactory({ student_signed_on: new Date().toISOString() }).one();
287
+
288
+ const checkSignatureDeferred = new Deferred();
289
+
290
+ fetchMock
291
+ .post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit_for_signature/`, {
292
+ invitation_link: 'https://dummysignaturebackend.fr/contract/1/sign',
293
+ })
294
+ .post(`https://joanie.endpoint/api/v1.0/signature/notifications/`, 200)
295
+ .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, checkSignatureDeferred.promise, {
296
+ overwriteRoutes: true,
297
+ })
298
+ .get(
299
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
300
+ [order],
301
+ { overwriteRoutes: true },
302
+ );
303
+
304
+ const $signButton = await screen.findByRole('button', { name: 'Sign' });
305
+ await user.click($signButton);
306
+
307
+ screen.getByRole('heading', { name: 'Signing the contract ...' });
308
+
309
+ // Then the signature check polling should be started
310
+ await screen.findByRole('heading', { name: 'Verifying signature ...' });
311
+ expect(
312
+ screen.getByText(
313
+ 'We are waiting for the signature to be validated from our signature platform. It can take up to few minutes. Do not close this page.',
314
+ ),
315
+ ).toBeInTheDocument();
316
+ expect(screen.getByRole('status')).toBeInTheDocument();
317
+
318
+ await act(async () => {
319
+ checkSignatureDeferred.resolve(order);
320
+ });
321
+
322
+ /**
323
+ * Save payment method step
324
+ */
325
+ const paymentMethod = CreditCardFactory().one();
326
+ order.state = OrderState.PENDING;
327
+ order.credit_card_id = paymentMethod.id;
328
+ fetchMock
329
+ .post('https://joanie.endpoint/api/v1.0/credit-cards/tokenize-card/', PaymentFactory().one())
330
+ .post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/payment-method/`, 200)
331
+ .get(
332
+ 'https://joanie.endpoint/api/v1.0/credit-cards/',
333
+ { results: [paymentMethod] },
334
+ { overwriteRoutes: true },
335
+ )
336
+ .get(
337
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
338
+ [order],
339
+ { overwriteRoutes: true },
340
+ );
341
+ await screen.findByRole('heading', { name: 'Define a payment method' });
342
+ screen.getByText('Use another credit card');
343
+
344
+ const $defineButton = screen.getByRole('button', { name: 'Define' });
345
+ await user.click($defineButton);
346
+
283
347
  screen.getByText('Payment interface component');
284
348
  await user.click(screen.getByTestId('payment-success'));
285
349
 
@@ -288,11 +352,9 @@ describe('SaleTunnel', () => {
288
352
  */
289
353
 
290
354
  // Make sure the success step is shown.
291
- expect(screen.queryByTestId('generic-sale-tunnel-payment-step')).not.toBeInTheDocument();
292
355
  await screen.findByTestId('generic-sale-tunnel-success-step');
293
- screen.getByText('Congratulations!');
294
- screen.getByText(/Your order has been successfully created/);
295
- screen.getByRole('link', { name: 'Sign the training contract' });
356
+ screen.getByText('Subscription confirmed!');
357
+ screen.getByRole('link', { name: 'Close' });
296
358
 
297
359
  /**
298
360
  * Make sure the product is displayed as bought ( it verifies cache is well updated ).