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.
- package/js/api/joanie.ts +12 -16
- package/js/api/lms/dummy.ts +1 -12
- package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +16 -9
- package/js/components/ContractFrame/AbstractContractFrame.tsx +28 -23
- package/js/components/ContractFrame/LearnerContractFrame.tsx +2 -2
- package/js/components/ContractFrame/_styles.scss +6 -14
- package/js/components/CreditCardSelector/index.spec.tsx +7 -7
- package/js/components/CreditCardSelector/index.tsx +2 -2
- package/js/components/DownloadContractButton/index.spec.tsx +1 -1
- package/js/components/OpenEdxFullNameForm/index.spec.tsx +229 -0
- package/js/components/OpenEdxFullNameForm/index.tsx +7 -7
- package/js/components/PaymentInterfaces/LyraPopIn.tsx +2 -2
- package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
- package/js/components/PaymentInterfaces/__mocks__/index.tsx +1 -4
- package/js/components/PaymentInterfaces/types.ts +5 -2
- package/js/components/PurchaseButton/index.spec.tsx +69 -37
- package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +2 -1
- package/js/components/SaleTunnel/CertificateSaleTunnel/index.tsx +2 -2
- package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +6 -10
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +75 -41
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +0 -30
- package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/_styles.scss +12 -0
- package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +160 -0
- package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +15 -29
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +5 -0
- package/js/components/SaleTunnel/SubscriptionButton/_styles.scss +7 -0
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +201 -0
- package/js/components/SaleTunnel/_styles.scss +10 -1
- package/js/components/SaleTunnel/hooks/useTerms.tsx +0 -77
- package/js/components/SaleTunnel/index.credential.spec.tsx +12 -21
- package/js/components/SaleTunnel/index.full-process.spec.tsx +110 -48
- package/js/components/SaleTunnel/index.spec.tsx +330 -779
- package/js/components/SignContractButton/index.omniscientOrders.spec.tsx +16 -11
- package/js/components/SignContractButton/index.spec.tsx +16 -20
- package/js/components/SignContractButton/index.tsx +3 -1
- package/js/hooks/useCreditCards/index.spec.tsx +70 -6
- package/js/hooks/useCreditCards/index.ts +49 -11
- package/js/hooks/useOrders/index.spec.tsx +322 -0
- package/js/hooks/{useOrders.ts → useOrders/index.ts} +40 -14
- package/js/hooks/useProductOrder/index.spec.tsx +77 -60
- package/js/hooks/useProductOrder/index.tsx +2 -2
- package/js/hooks/useResources/useResourcesRoot.ts +1 -0
- package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.spec.tsx +1 -1
- package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.tsx +4 -2
- package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +8 -5
- package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +8 -9
- package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +1 -1
- package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +1 -6
- package/js/settings/settings.test.ts +11 -2
- package/js/types/Joanie.ts +49 -34
- package/js/utils/OrderHelper/index.ts +38 -42
- package/js/utils/test/factories/joanie.ts +36 -51
- package/js/widgets/Dashboard/components/DashboardItem/CourseEnrolling/index.tsx +8 -18
- package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +26 -32
- package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +11 -6
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +7 -6
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +9 -10
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +3 -1
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +6 -7
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +28 -8
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +2 -5
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +18 -71
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +34 -35
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +27 -24
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +18 -73
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +32 -16
- package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +3 -11
- package/js/widgets/Dashboard/components/Signature/SignatureDummy.tsx +25 -3
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/EnrollableCourseRunList.tsx +2 -6
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +7 -14
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.spec.tsx +7 -5
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.tsx +5 -7
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +242 -332
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +12 -13
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +10 -21
- package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +2 -2
- package/package.json +1 -1
- package/scss/components/_index.scss +2 -1
- package/js/components/PaymentButton/_styles.scss +0 -27
- package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +0 -333
- package/js/components/SaleTunnel/SaleTunnelNotValidated/index.tsx +0 -70
- 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-
|
|
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
|
-
|
|
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 {
|
|
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/', (
|
|
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
|
-
|
|
17
|
-
PaymentInstallmentFactory,
|
|
17
|
+
ContractFactory,
|
|
18
18
|
CourseProductRelationFactory,
|
|
19
|
+
CredentialOrderFactory,
|
|
20
|
+
CreditCardFactory,
|
|
21
|
+
PaymentFactory,
|
|
22
|
+
PaymentInstallmentFactory,
|
|
19
23
|
} from 'utils/test/factories/joanie';
|
|
20
|
-
import {
|
|
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
|
|
95
|
+
it('tests the entire process of subscribing to a credential product', async () => {
|
|
85
96
|
/**
|
|
86
97
|
* Initialization.
|
|
87
98
|
*/
|
|
88
|
-
const
|
|
99
|
+
const course = PacedCourseFactory().one();
|
|
100
|
+
const relation = CourseProductRelationFactory({ course }).one();
|
|
89
101
|
const paymentSchedule = PaymentInstallmentFactory().many(2);
|
|
90
|
-
const { product
|
|
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:
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
245
|
+
* Make sure the payment schedule is displayed.
|
|
239
246
|
*/
|
|
240
|
-
screen.getByRole('heading', {
|
|
241
|
-
|
|
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
|
-
*
|
|
269
|
+
* Subscribe
|
|
256
270
|
*/
|
|
257
|
-
const
|
|
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
|
-
|
|
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('
|
|
294
|
-
screen.
|
|
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 ).
|