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
|
@@ -57,16 +57,7 @@ type Story = StoryObj<typeof CourseProductItem>;
|
|
|
57
57
|
|
|
58
58
|
export const Default: Story = {};
|
|
59
59
|
|
|
60
|
-
export const
|
|
61
|
-
args: {
|
|
62
|
-
productId: 'AAA',
|
|
63
|
-
course: PacedCourseFactory({ code: 'BBB' }).one(),
|
|
64
|
-
},
|
|
65
|
-
render: (args) =>
|
|
66
|
-
render(args, { order: CredentialOrderFactory({ state: OrderState.PENDING }).one() }),
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export const WithValidatedOrder: Story = {
|
|
60
|
+
export const WithCompletedOrder: Story = {
|
|
70
61
|
args: {
|
|
71
62
|
productId: 'AAA',
|
|
72
63
|
course: PacedCourseFactory({ code: 'BBB' }).one(),
|
|
@@ -75,7 +66,7 @@ export const WithValidatedOrder: Story = {
|
|
|
75
66
|
const courseRunWithEnrollment = CourseRunFactory().one();
|
|
76
67
|
return render(args, {
|
|
77
68
|
order: CredentialOrderFactory({
|
|
78
|
-
state: OrderState.
|
|
69
|
+
state: OrderState.COMPLETED,
|
|
79
70
|
target_enrollments: EnrollmentFactory({
|
|
80
71
|
is_active: true,
|
|
81
72
|
course_run: courseRunWithEnrollment,
|
|
@@ -91,11 +82,19 @@ export const WithValidatedOrder: Story = {
|
|
|
91
82
|
},
|
|
92
83
|
};
|
|
93
84
|
|
|
94
|
-
export const
|
|
85
|
+
export const WithPendingOrder: Story = {
|
|
95
86
|
args: {
|
|
96
87
|
productId: 'AAA',
|
|
97
88
|
course: PacedCourseFactory({ code: 'BBB' }).one(),
|
|
98
89
|
},
|
|
99
90
|
render: (args) =>
|
|
100
|
-
render(args, { order: CredentialOrderFactory({ state: OrderState.
|
|
91
|
+
render(args, { order: CredentialOrderFactory({ state: OrderState.PENDING }).one() }),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const WithNoOrder: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
productId: 'AAA',
|
|
97
|
+
course: PacedCourseFactory({ code: 'BBB' }).one(),
|
|
98
|
+
},
|
|
99
|
+
render: (args) => render(args),
|
|
101
100
|
};
|
|
@@ -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,
|
|
4
|
+
import { ProductType, Product, CredentialOrder, PURCHASABLE_ORDER_STATES } 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';
|
|
@@ -11,7 +11,6 @@ import { ProductHelper } from 'utils/ProductHelper';
|
|
|
11
11
|
import useProductOrder from 'hooks/useProductOrder';
|
|
12
12
|
import { OrderHelper } from 'utils/OrderHelper';
|
|
13
13
|
import { handle } from 'utils/errors/handle';
|
|
14
|
-
import { ProductSignatureHeader } from 'widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader';
|
|
15
14
|
import { PacedCourse } from 'types';
|
|
16
15
|
import CertificateItem from './components/CourseProductCertificateItem';
|
|
17
16
|
import CourseRunItem from './components/CourseRunItem';
|
|
@@ -23,12 +22,6 @@ const messages = defineMessages({
|
|
|
23
22
|
description: 'Message displayed when authenticated user owned the product',
|
|
24
23
|
id: 'components.CourseProductItem.purchased',
|
|
25
24
|
},
|
|
26
|
-
pending: {
|
|
27
|
-
defaultMessage: 'Pending',
|
|
28
|
-
description:
|
|
29
|
-
'Message displayed when authenticated user has purchased the product but order is still pending',
|
|
30
|
-
id: 'components.CourseProductItem.pending',
|
|
31
|
-
},
|
|
32
25
|
loading: {
|
|
33
26
|
defaultMessage: 'Loading product information...',
|
|
34
27
|
description:
|
|
@@ -64,10 +57,10 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
|
|
|
64
57
|
const intl = useIntl();
|
|
65
58
|
const formatDate = useDateFormat();
|
|
66
59
|
|
|
67
|
-
// compact mode is available for product until they got
|
|
60
|
+
// compact mode is available for product until they got an active order.
|
|
68
61
|
const canShowMetadata = useMemo(() => {
|
|
69
|
-
return compact && (!order ||
|
|
70
|
-
}, [compact, hasPurchased]);
|
|
62
|
+
return compact && (!order || canPurchase);
|
|
63
|
+
}, [compact, hasPurchased, canPurchase]);
|
|
71
64
|
|
|
72
65
|
const [minDate, maxDate] = useMemo(() => {
|
|
73
66
|
if (!canShowMetadata) return [undefined, undefined];
|
|
@@ -84,8 +77,7 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
|
|
|
84
77
|
<div className="product-widget__header-main">
|
|
85
78
|
<h3 className="product-widget__title">{product.title}</h3>
|
|
86
79
|
<strong className="product-widget__price h6">
|
|
87
|
-
{
|
|
88
|
-
{order?.state === OrderState.SUBMITTED && <FormattedMessage {...messages.pending} />}
|
|
80
|
+
{hasPurchased && <FormattedMessage {...messages.purchased} />}
|
|
89
81
|
{canPurchase && (
|
|
90
82
|
<FormattedNumber
|
|
91
83
|
currency={product.price_currency}
|
|
@@ -123,9 +115,6 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
|
|
|
123
115
|
);
|
|
124
116
|
};
|
|
125
117
|
const Content = ({ product, order }: { product: Product; order?: CredentialOrder }) => {
|
|
126
|
-
const needsSignature = order
|
|
127
|
-
? OrderHelper.orderNeedsSignature(order, product.contract_definition)
|
|
128
|
-
: false;
|
|
129
118
|
const targetCourses = useMemo(() => {
|
|
130
119
|
if (order) {
|
|
131
120
|
return order.target_courses;
|
|
@@ -140,10 +129,9 @@ const Content = ({ product, order }: { product: Product; order?: CredentialOrder
|
|
|
140
129
|
|
|
141
130
|
return (
|
|
142
131
|
<ol className="product-widget__content">
|
|
143
|
-
{needsSignature && <ProductSignatureHeader order={order} />}
|
|
144
132
|
{Children.toArray(
|
|
145
133
|
targetCourses.map((target_course) => (
|
|
146
|
-
<CourseRunItem targetCourse={target_course} order={order}
|
|
134
|
+
<CourseRunItem targetCourse={target_course} order={order} />
|
|
147
135
|
)),
|
|
148
136
|
)}
|
|
149
137
|
{product.certificate_definition && (
|
|
@@ -168,12 +156,13 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
|
|
|
168
156
|
});
|
|
169
157
|
|
|
170
158
|
const order = productOrder as CredentialOrder;
|
|
171
|
-
const canPurchase = !order || order.state
|
|
172
|
-
const hasPurchased = (order
|
|
159
|
+
const canPurchase = !order || PURCHASABLE_ORDER_STATES.includes(order.state);
|
|
160
|
+
const hasPurchased = OrderHelper.isActive(order);
|
|
161
|
+
const canEnroll = OrderHelper.allowEnrollment(order);
|
|
173
162
|
|
|
174
163
|
const hasError = Boolean(productQueryStates.error);
|
|
175
164
|
const isFetching = productQueryStates.fetching || orderQueryStates.fetching;
|
|
176
|
-
const canShowContent = !compact ||
|
|
165
|
+
const canShowContent = !compact || canEnroll;
|
|
177
166
|
|
|
178
167
|
useEffect(() => {
|
|
179
168
|
if (product && product.type !== ProductType.CREDENTIAL) {
|
package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx
CHANGED
|
@@ -2,12 +2,12 @@ import { act, fireEvent, screen } from '@testing-library/react';
|
|
|
2
2
|
import fetchMock from 'fetch-mock';
|
|
3
3
|
|
|
4
4
|
import { faker } from '@faker-js/faker';
|
|
5
|
-
import { Deferred } from 'utils/test/deferred';
|
|
6
|
-
import { EnrollmentFactory as JoanieEnrollment } from 'utils/test/factories/joanie';
|
|
7
5
|
import {
|
|
8
6
|
CourseRunFactory,
|
|
9
7
|
RichieContextFactory as mockRichieContextFactory,
|
|
10
8
|
} from 'utils/test/factories/richie';
|
|
9
|
+
import { Deferred } from 'utils/test/deferred';
|
|
10
|
+
import { EnrollmentFactory as JoanieEnrollment } from 'utils/test/factories/joanie';
|
|
11
11
|
import { HttpStatusCode } from 'utils/errors/HttpError';
|
|
12
12
|
import { Priority } from 'types';
|
|
13
13
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
package/package.json
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
@import '../../js/components/Tabs/styles';
|
|
5
5
|
@import '../../js/components/TeacherDashboardCourseList/styles';
|
|
6
6
|
@import '../../js/components/Modal/styles';
|
|
7
|
-
@import '../../js/components/
|
|
7
|
+
@import '../../js/components/SaleTunnel/SubscriptionButton/styles';
|
|
8
8
|
@import '../../js/components/PaymentScheduleGrid/styles';
|
|
9
9
|
@import '../../js/components/PurchaseButton/styles';
|
|
10
10
|
@import '../../js/components/SaleTunnel/styles';
|
|
11
11
|
@import '../../js/components/SaleTunnel/AddressSelector/styles';
|
|
12
12
|
@import '../../js/components/SaleTunnel/ProductPath/styles';
|
|
13
13
|
@import '../../js/components/SaleTunnel/Sponsors/SaleTunnelSponsors';
|
|
14
|
+
@import '../../js/components/SaleTunnel/SaleTunnelSavePaymentMethod/styles';
|
|
14
15
|
@import '../../js/components/SuccessIcon/styles';
|
|
15
16
|
@import '../../js/components/WarningIcon/styles';
|
|
16
17
|
@import '../../js/components/RegisteredAddress/styles';
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
.payment-button {
|
|
2
|
-
text-align: center;
|
|
3
|
-
display: flex;
|
|
4
|
-
flex-direction: column;
|
|
5
|
-
justify-content: center;
|
|
6
|
-
align-items: center;
|
|
7
|
-
|
|
8
|
-
&__error {
|
|
9
|
-
color: r-color('firebrick6');
|
|
10
|
-
margin-top: 0.5rem;
|
|
11
|
-
margin-bottom: 0;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
&__terms {
|
|
15
|
-
button {
|
|
16
|
-
color: $link-color;
|
|
17
|
-
text-decoration: underline;
|
|
18
|
-
background-color: transparent;
|
|
19
|
-
border: none;
|
|
20
|
-
padding: 0;
|
|
21
|
-
|
|
22
|
-
&:hover {
|
|
23
|
-
color: $link-hover-color;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Button } from '@openfun/cunningham-react';
|
|
4
|
-
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
5
|
-
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
6
|
-
import { useOrders } from 'hooks/useOrders';
|
|
7
|
-
import { OrderCreationPayload, OrderState } from 'types/Joanie';
|
|
8
|
-
import type { Maybe } from 'types/utils';
|
|
9
|
-
import { useTerms } from 'components/SaleTunnel/hooks/useTerms';
|
|
10
|
-
import WebAnalyticsAPIHandler from 'api/web-analytics';
|
|
11
|
-
import { CourseProductEvent } from 'types/web-analytics';
|
|
12
|
-
import { ObjectHelper } from 'utils/ObjectHelper';
|
|
13
|
-
import { HttpError } from 'utils/errors/HttpError';
|
|
14
|
-
import { PAYMENT_SETTINGS } from 'settings';
|
|
15
|
-
import { Spinner } from 'components/Spinner';
|
|
16
|
-
import PaymentInterface from 'components/PaymentInterfaces';
|
|
17
|
-
import { useMatchMediaLg } from 'hooks/useMatchMedia';
|
|
18
|
-
import { PaymentErrorMessageId, Payment, PaymentWithId } from 'components/PaymentInterfaces/types';
|
|
19
|
-
|
|
20
|
-
const messages = defineMessages({
|
|
21
|
-
errorAbort: {
|
|
22
|
-
defaultMessage: 'You have aborted the payment.',
|
|
23
|
-
description: 'Error message shown when user aborts the payment.',
|
|
24
|
-
id: 'components.PaymentButton.errorAbort',
|
|
25
|
-
},
|
|
26
|
-
errorAborting: {
|
|
27
|
-
defaultMessage: 'Aborting the payment...',
|
|
28
|
-
description: 'Error message shown when user asks to abort the payment.',
|
|
29
|
-
id: 'components.PaymentButton.errorAborting',
|
|
30
|
-
},
|
|
31
|
-
errorDefault: {
|
|
32
|
-
defaultMessage: 'An error occurred during payment. Please retry later.',
|
|
33
|
-
description: 'Error message shown when payment creation request failed.',
|
|
34
|
-
id: 'components.PaymentButton.errorDefault',
|
|
35
|
-
},
|
|
36
|
-
errorFullProduct: {
|
|
37
|
-
defaultMessage: 'There are no more places available for this product.',
|
|
38
|
-
description:
|
|
39
|
-
'Error message shown when payment creation request failed because there is no remaining available seat for the product.',
|
|
40
|
-
id: 'components.PaymentButton.errorFullProduct',
|
|
41
|
-
},
|
|
42
|
-
errorAddress: {
|
|
43
|
-
defaultMessage: 'You must have a billing address.',
|
|
44
|
-
description: "Error message shown when the user didn't select a billing address.",
|
|
45
|
-
id: 'components.PaymentButton.errorAddress',
|
|
46
|
-
},
|
|
47
|
-
errorTerms: {
|
|
48
|
-
defaultMessage: 'You must accept the terms.',
|
|
49
|
-
description: "Error message shown when the user didn't check the terms checkbox.",
|
|
50
|
-
id: 'components.PaymentButton.errorTerms',
|
|
51
|
-
},
|
|
52
|
-
pay: {
|
|
53
|
-
defaultMessage: 'Subscribe',
|
|
54
|
-
description: 'CTA label to proceed to the payment of the product',
|
|
55
|
-
id: 'components.PaymentButton.pay',
|
|
56
|
-
},
|
|
57
|
-
paymentInProgress: {
|
|
58
|
-
defaultMessage: 'Payment in progress',
|
|
59
|
-
description: 'Label for screen reader when a payment is in progress.',
|
|
60
|
-
id: 'components.PaymentButton.paymentInProgress',
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
type PaymentInfo = Payment & { order_id: string };
|
|
65
|
-
|
|
66
|
-
enum ComponentStates {
|
|
67
|
-
IDLE = 'idle',
|
|
68
|
-
LOADING = 'loading',
|
|
69
|
-
ERROR = 'error',
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface Props {
|
|
73
|
-
buildOrderPayload: (
|
|
74
|
-
payload: Pick<OrderCreationPayload, 'product_id' | 'has_consent_to_terms'>,
|
|
75
|
-
) => OrderCreationPayload;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const GenericPaymentButton = ({ buildOrderPayload }: Props) => {
|
|
79
|
-
const intl = useIntl();
|
|
80
|
-
const API = useJoanieApi();
|
|
81
|
-
const timeoutRef = useRef<NodeJS.Timeout>();
|
|
82
|
-
const {
|
|
83
|
-
webAnalyticsEventKey,
|
|
84
|
-
order,
|
|
85
|
-
billingAddress,
|
|
86
|
-
creditCard,
|
|
87
|
-
product,
|
|
88
|
-
onPaymentSuccess,
|
|
89
|
-
props: saleTunnelProps,
|
|
90
|
-
runSubmitCallbacks,
|
|
91
|
-
} = useSaleTunnelContext();
|
|
92
|
-
const { methods: orderMethods } = useOrders(undefined, { enabled: false });
|
|
93
|
-
const [payment, setPayment] = useState<PaymentInfo>();
|
|
94
|
-
const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
|
|
95
|
-
const [error, setError] = useState<PaymentErrorMessageId | string>();
|
|
96
|
-
const hasPaymentId = (p: Maybe<Payment>): p is Extract<Payment, PaymentWithId> => {
|
|
97
|
-
return Boolean(p?.hasOwnProperty('payment_id'));
|
|
98
|
-
};
|
|
99
|
-
const paymentId = hasPaymentId(payment) ? payment.payment_id : undefined;
|
|
100
|
-
const isMobile = useMatchMediaLg();
|
|
101
|
-
|
|
102
|
-
// This pattern is ugly but I couldn't find a better way to achieve it in a nicer way.
|
|
103
|
-
// Without this, when we call onPaymentSuccess() directly from the after polling function,
|
|
104
|
-
// it is not the latest version of the function, so the value of `order` inside the context function (defined in GenericSaleTunnel.tsx)
|
|
105
|
-
// will be undefined, then calling onFinish(order) with an undefined order.
|
|
106
|
-
const onPaymentSuccessRef = useRef(onPaymentSuccess);
|
|
107
|
-
onPaymentSuccessRef.current = onPaymentSuccess;
|
|
108
|
-
|
|
109
|
-
const { validateTerms, termsAccepted, renderTermsCheckbox } = useTerms({
|
|
110
|
-
product,
|
|
111
|
-
error,
|
|
112
|
-
onError: (e) => {
|
|
113
|
-
handleError(e);
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const isReadyToPay = useMemo(() => {
|
|
118
|
-
return (
|
|
119
|
-
(saleTunnelProps.course || saleTunnelProps.enrollment) &&
|
|
120
|
-
product &&
|
|
121
|
-
billingAddress &&
|
|
122
|
-
termsAccepted
|
|
123
|
-
);
|
|
124
|
-
}, [product, saleTunnelProps.course, saleTunnelProps.enrollment, billingAddress, termsAccepted]);
|
|
125
|
-
|
|
126
|
-
const isBusy = useMemo(() => {
|
|
127
|
-
return (
|
|
128
|
-
state === ComponentStates.LOADING ||
|
|
129
|
-
(state === ComponentStates.ERROR && error === PaymentErrorMessageId.ERROR_ABORTING)
|
|
130
|
-
);
|
|
131
|
-
}, [state, error]);
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Use Joanie API to retrieve an order and check if it's state is validated
|
|
135
|
-
*
|
|
136
|
-
* @param {string} id - Order id
|
|
137
|
-
* @returns {Promise<boolean>} - Promise resolving to true if order is validated
|
|
138
|
-
*/
|
|
139
|
-
const isOrderValidated = async (id: string): Promise<Boolean> => {
|
|
140
|
-
const orderToCheck = await API.user.orders.get({ id });
|
|
141
|
-
return orderToCheck?.state === OrderState.VALIDATED;
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const createPayment = async (orderId: string) => {
|
|
145
|
-
WebAnalyticsAPIHandler()?.sendCourseProductEvent(
|
|
146
|
-
CourseProductEvent.PAYMENT_CREATION,
|
|
147
|
-
webAnalyticsEventKey,
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
if (!billingAddress) {
|
|
151
|
-
handleError(PaymentErrorMessageId.ERROR_ADDRESS);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
validateTerms();
|
|
155
|
-
|
|
156
|
-
if (isReadyToPay) {
|
|
157
|
-
setState(ComponentStates.LOADING);
|
|
158
|
-
setError(undefined);
|
|
159
|
-
|
|
160
|
-
if (!payment) {
|
|
161
|
-
const billingAddressPayload = ObjectHelper.omit(billingAddress!, 'id', 'is_main');
|
|
162
|
-
|
|
163
|
-
orderMethods.submit(
|
|
164
|
-
{
|
|
165
|
-
id: orderId,
|
|
166
|
-
billing_address: billingAddressPayload,
|
|
167
|
-
...(creditCard && { credit_card_id: creditCard.id }),
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
onSuccess: (orderPayment) => {
|
|
171
|
-
const paymentInfos = {
|
|
172
|
-
...orderPayment.payment_info,
|
|
173
|
-
order_id: orderId,
|
|
174
|
-
};
|
|
175
|
-
setPayment(paymentInfos);
|
|
176
|
-
},
|
|
177
|
-
onError: async (createPaymentError: HttpError) => {
|
|
178
|
-
if (createPaymentError.responseBody) {
|
|
179
|
-
const responseErrors = await createPaymentError.responseBody;
|
|
180
|
-
if ('max_validated_orders' in responseErrors) {
|
|
181
|
-
handleError(PaymentErrorMessageId.ERROR_FULL_PRODUCT);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
handleError();
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const createOrder = async () => {
|
|
193
|
-
setState(ComponentStates.LOADING);
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
await runSubmitCallbacks();
|
|
197
|
-
} catch (e) {
|
|
198
|
-
// Example: full name failed saving to OpenEDX.
|
|
199
|
-
setState(ComponentStates.IDLE);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (!billingAddress) {
|
|
204
|
-
handleError(PaymentErrorMessageId.ERROR_ADDRESS);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
validateTerms();
|
|
208
|
-
|
|
209
|
-
if (!isReadyToPay) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (order) {
|
|
214
|
-
createPayment(order.id);
|
|
215
|
-
} else {
|
|
216
|
-
const payload = buildOrderPayload({
|
|
217
|
-
product_id: product.id,
|
|
218
|
-
has_consent_to_terms: termsAccepted,
|
|
219
|
-
});
|
|
220
|
-
orderMethods.create(payload, {
|
|
221
|
-
onSuccess: (newOrder) => {
|
|
222
|
-
createPayment(newOrder.id);
|
|
223
|
-
},
|
|
224
|
-
onError: async () => {
|
|
225
|
-
handleError();
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const handleSuccess = () => {
|
|
232
|
-
let round = 0;
|
|
233
|
-
|
|
234
|
-
const checkOrderValidity = async () => {
|
|
235
|
-
if (round >= PAYMENT_SETTINGS.pollLimit) {
|
|
236
|
-
timeoutRef.current = undefined;
|
|
237
|
-
onPaymentSuccessRef.current(false);
|
|
238
|
-
} else {
|
|
239
|
-
const isValidated = await isOrderValidated(payment!.order_id);
|
|
240
|
-
if (isValidated) {
|
|
241
|
-
setState(ComponentStates.IDLE);
|
|
242
|
-
timeoutRef.current = undefined;
|
|
243
|
-
onPaymentSuccessRef.current();
|
|
244
|
-
} else {
|
|
245
|
-
round++;
|
|
246
|
-
timeoutRef.current = setTimeout(checkOrderValidity, PAYMENT_SETTINGS.pollInterval);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
checkOrderValidity();
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const handleError = (
|
|
255
|
-
messageId: PaymentErrorMessageId | string = PaymentErrorMessageId.ERROR_DEFAULT,
|
|
256
|
-
) => {
|
|
257
|
-
setState(ComponentStates.ERROR);
|
|
258
|
-
setError(messageId);
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
useEffect(() => {
|
|
262
|
-
if (error === PaymentErrorMessageId.ERROR_ABORTING) {
|
|
263
|
-
orderMethods
|
|
264
|
-
.abort({
|
|
265
|
-
id: payment!.order_id,
|
|
266
|
-
payment_id: paymentId,
|
|
267
|
-
})
|
|
268
|
-
.then(() => {
|
|
269
|
-
handleError(PaymentErrorMessageId.ERROR_ABORT);
|
|
270
|
-
})
|
|
271
|
-
.catch(() => {
|
|
272
|
-
handleError();
|
|
273
|
-
});
|
|
274
|
-
} else if (error && !messages.hasOwnProperty(error)) {
|
|
275
|
-
orderMethods.invalidate();
|
|
276
|
-
} else if (state === ComponentStates.ERROR) {
|
|
277
|
-
setPayment(undefined);
|
|
278
|
-
}
|
|
279
|
-
}, [error]);
|
|
280
|
-
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
if (state === ComponentStates.ERROR) {
|
|
283
|
-
document.querySelector<HTMLElement>('#sale-tunnel-payment-error')?.focus();
|
|
284
|
-
}
|
|
285
|
-
}, [state]);
|
|
286
|
-
|
|
287
|
-
return (
|
|
288
|
-
<>
|
|
289
|
-
{renderTermsCheckbox()}
|
|
290
|
-
<Button
|
|
291
|
-
disabled={isBusy}
|
|
292
|
-
onClick={createOrder}
|
|
293
|
-
data-testid={order && 'payment-button-order-loaded'}
|
|
294
|
-
fullWidth={isMobile}
|
|
295
|
-
{...(state === ComponentStates.ERROR && {
|
|
296
|
-
'aria-describedby': 'sale-tunnel-payment-error',
|
|
297
|
-
})}
|
|
298
|
-
>
|
|
299
|
-
{isBusy ? (
|
|
300
|
-
<Spinner theme="light" aria-labelledby="payment-in-progress">
|
|
301
|
-
<span id="payment-in-progress">
|
|
302
|
-
<FormattedMessage {...messages.paymentInProgress} />
|
|
303
|
-
</span>
|
|
304
|
-
</Spinner>
|
|
305
|
-
) : (
|
|
306
|
-
<FormattedMessage
|
|
307
|
-
{...messages.pay}
|
|
308
|
-
values={{
|
|
309
|
-
price: intl.formatNumber(product.price, {
|
|
310
|
-
style: 'currency',
|
|
311
|
-
currency: product.price_currency,
|
|
312
|
-
}),
|
|
313
|
-
}}
|
|
314
|
-
/>
|
|
315
|
-
)}
|
|
316
|
-
</Button>
|
|
317
|
-
{state === ComponentStates.LOADING && payment && (
|
|
318
|
-
<PaymentInterface {...payment} onError={handleError} onSuccess={handleSuccess} />
|
|
319
|
-
)}
|
|
320
|
-
{state === ComponentStates.ERROR && (
|
|
321
|
-
<p className="payment-button__error" id="sale-tunnel-payment-error" tabIndex={-1}>
|
|
322
|
-
{!error || messages.hasOwnProperty(error) ? (
|
|
323
|
-
<FormattedMessage
|
|
324
|
-
{...messages[(error as PaymentErrorMessageId) || PaymentErrorMessageId.ERROR_DEFAULT]}
|
|
325
|
-
/>
|
|
326
|
-
) : (
|
|
327
|
-
error
|
|
328
|
-
)}
|
|
329
|
-
</p>
|
|
330
|
-
)}
|
|
331
|
-
</>
|
|
332
|
-
);
|
|
333
|
-
};
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
|
-
import { Button } from '@openfun/cunningham-react';
|
|
3
|
-
import { generatePath } from 'react-router-dom';
|
|
4
|
-
import WarningIcon from 'components/WarningIcon';
|
|
5
|
-
import { getDashboardBasename } from 'widgets/Dashboard/hooks/useDashboardRouter/getDashboardBasename';
|
|
6
|
-
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
7
|
-
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
8
|
-
import { ProductType } from 'types/Joanie';
|
|
9
|
-
|
|
10
|
-
const messages = defineMessages({
|
|
11
|
-
apology: {
|
|
12
|
-
defaultMessage: "Sorry, you'll have to wait a little longer!",
|
|
13
|
-
description: 'Text displayed to thank user for his order',
|
|
14
|
-
id: 'components.SaleTunnelSuccessNotValidated.apology',
|
|
15
|
-
},
|
|
16
|
-
title: {
|
|
17
|
-
defaultMessage: 'It takes too long to validate your order.',
|
|
18
|
-
description: 'Message to confirm that order has been created',
|
|
19
|
-
id: 'components.SaleTunnelSuccessNotValidated.title',
|
|
20
|
-
},
|
|
21
|
-
description: {
|
|
22
|
-
defaultMessage:
|
|
23
|
-
'Your payment has succeeded but your order validation is taking too long, you can close this dialog and come back later. You will receive your invoice by email in a few moments.',
|
|
24
|
-
description: "Text to remind that order's invoice will be send by email soon",
|
|
25
|
-
id: 'components.SaleTunnelSuccessNotValidated.description',
|
|
26
|
-
},
|
|
27
|
-
cta: {
|
|
28
|
-
defaultMessage: 'Close',
|
|
29
|
-
description: 'Label to the call to action to close sale tunnel',
|
|
30
|
-
id: 'components.SaleTunnelSuccessNotValidated.cta',
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const SaleTunnelNotValidated = ({ closeModal }: { closeModal: () => void }) => {
|
|
35
|
-
const intl = useIntl();
|
|
36
|
-
const { order, product } = useSaleTunnelContext();
|
|
37
|
-
return (
|
|
38
|
-
<section className="sale-tunnel-end" data-testid="generic-sale-tunnel-not-validated-step">
|
|
39
|
-
<header className="sale-tunnel-end__header">
|
|
40
|
-
<WarningIcon />
|
|
41
|
-
<h3 className="sale-tunnel-end__title">
|
|
42
|
-
<FormattedMessage {...messages.apology} />
|
|
43
|
-
</h3>
|
|
44
|
-
</header>
|
|
45
|
-
<p className="sale-tunnel-end__content">
|
|
46
|
-
<FormattedMessage {...messages.title} />
|
|
47
|
-
<br />
|
|
48
|
-
<FormattedMessage {...messages.description} />
|
|
49
|
-
</p>
|
|
50
|
-
<footer className="sale-tunnel-end__footer">
|
|
51
|
-
{product.type === ProductType.CREDENTIAL ? (
|
|
52
|
-
<Button
|
|
53
|
-
href={
|
|
54
|
-
getDashboardBasename(intl.locale) +
|
|
55
|
-
generatePath(LearnerDashboardPaths.ORDER, { orderId: order!.id })
|
|
56
|
-
}
|
|
57
|
-
>
|
|
58
|
-
<FormattedMessage {...messages.cta} />
|
|
59
|
-
</Button>
|
|
60
|
-
) : (
|
|
61
|
-
<Button onClick={closeModal}>
|
|
62
|
-
<FormattedMessage {...messages.cta} />
|
|
63
|
-
</Button>
|
|
64
|
-
)}
|
|
65
|
-
</footer>
|
|
66
|
-
</section>
|
|
67
|
-
);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export default SaleTunnelNotValidated;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { Button } from '@openfun/cunningham-react';
|
|
2
|
-
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
|
-
import { generatePath } from 'react-router-dom';
|
|
4
|
-
import Banner, { BannerType } from 'components/Banner';
|
|
5
|
-
import { getDashboardBasename } from 'widgets/Dashboard/hooks/useDashboardRouter/getDashboardBasename';
|
|
6
|
-
import { Order } from 'types/Joanie';
|
|
7
|
-
|
|
8
|
-
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
9
|
-
|
|
10
|
-
const messages = defineMessages({
|
|
11
|
-
signatureNeeded: {
|
|
12
|
-
defaultMessage: 'You need to sign your training contract before enrolling to course runs',
|
|
13
|
-
description: 'Banner displayed when the contract is not signed on the syllabus',
|
|
14
|
-
id: 'components.CourseProductItem.signatureNeeded',
|
|
15
|
-
},
|
|
16
|
-
contractSignActionLabel: {
|
|
17
|
-
id: 'components.CourseProductItem.contractSignActionLabel',
|
|
18
|
-
description: 'Label of "sign contract" action on the syllabus.',
|
|
19
|
-
defaultMessage: 'Sign your training contract',
|
|
20
|
-
},
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export const ProductSignatureHeader = ({ order }: { order?: Order }) => {
|
|
24
|
-
const intl = useIntl();
|
|
25
|
-
return (
|
|
26
|
-
<>
|
|
27
|
-
<Banner message={intl.formatMessage(messages.signatureNeeded)} type={BannerType.ERROR} />
|
|
28
|
-
<Button
|
|
29
|
-
fullWidth={true}
|
|
30
|
-
className="mb-s"
|
|
31
|
-
size="small"
|
|
32
|
-
href={
|
|
33
|
-
getDashboardBasename(intl.locale) +
|
|
34
|
-
generatePath(LearnerDashboardPaths.ORDER, { orderId: order!.id })
|
|
35
|
-
}
|
|
36
|
-
>
|
|
37
|
-
<FormattedMessage {...messages.contractSignActionLabel} />
|
|
38
|
-
</Button>
|
|
39
|
-
</>
|
|
40
|
-
);
|
|
41
|
-
};
|