richie-education 2.28.2-dev26 → 2.28.2-dev39
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 +30 -1
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
- package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
- package/js/components/PaymentScheduleGrid/index.tsx +50 -70
- package/js/components/PurchaseButton/index.spec.tsx +27 -12
- package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
- package/js/components/SaleTunnel/_styles.scss +6 -4
- package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
- package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
- package/js/components/SaleTunnel/index.spec.tsx +127 -61
- package/js/hooks/usePaymentSchedule.tsx +23 -0
- package/js/hooks/useResources/useResourcesRoot.ts +3 -3
- package/js/index.tsx +2 -0
- package/js/types/Joanie.ts +31 -0
- package/js/utils/OrderHelper/index.ts +13 -0
- package/js/utils/test/factories/joanie.ts +32 -19
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
- package/package.json +2 -1
- package/scss/components/_index.scss +2 -1
- /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
|
@@ -4,40 +4,42 @@ import {
|
|
|
4
4
|
Address,
|
|
5
5
|
Certificate,
|
|
6
6
|
CertificateDefinition,
|
|
7
|
-
|
|
7
|
+
CertificateOrder,
|
|
8
|
+
CertificateOrderWithPaymentInfo,
|
|
9
|
+
CertificateProduct,
|
|
10
|
+
Contract,
|
|
11
|
+
ContractDefinition,
|
|
12
|
+
ContractLight,
|
|
8
13
|
CourseLight,
|
|
14
|
+
CourseListItem,
|
|
9
15
|
CourseProduct,
|
|
10
16
|
CourseProductRelation,
|
|
11
17
|
CourseRun,
|
|
18
|
+
CredentialOrder,
|
|
19
|
+
CredentialOrderWithPaymentInfo,
|
|
20
|
+
CredentialProduct,
|
|
12
21
|
CreditCard,
|
|
13
22
|
CreditCardBrand,
|
|
23
|
+
DefinitionResourcesProduct,
|
|
14
24
|
Enrollment,
|
|
25
|
+
EnrollmentLight,
|
|
15
26
|
EnrollmentState,
|
|
27
|
+
JoanieFile,
|
|
28
|
+
NestedCertificateOrder,
|
|
29
|
+
NestedCourseOrder,
|
|
30
|
+
NestedCredentialOrder,
|
|
31
|
+
Order,
|
|
32
|
+
OrderEnrollment,
|
|
33
|
+
OrderGroup,
|
|
34
|
+
PaymentInstallment,
|
|
16
35
|
OrderLite,
|
|
17
36
|
OrderState,
|
|
18
37
|
Organization,
|
|
19
38
|
OrganizationLight,
|
|
39
|
+
PaymentScheduleState,
|
|
20
40
|
ProductType,
|
|
21
41
|
TargetCourse,
|
|
22
|
-
JoanieFile,
|
|
23
|
-
Contract,
|
|
24
|
-
OrderEnrollment,
|
|
25
|
-
ContractDefinition,
|
|
26
|
-
NestedCredentialOrder,
|
|
27
|
-
NestedCertificateOrder,
|
|
28
|
-
Order,
|
|
29
|
-
CertificateOrder,
|
|
30
|
-
CredentialOrder,
|
|
31
|
-
CertificateOrderWithPaymentInfo,
|
|
32
|
-
CredentialOrderWithPaymentInfo,
|
|
33
|
-
EnrollmentLight,
|
|
34
|
-
OrderGroup,
|
|
35
|
-
CertificateProduct,
|
|
36
|
-
CredentialProduct,
|
|
37
|
-
NestedCourseOrder,
|
|
38
42
|
UserLight,
|
|
39
|
-
ContractLight,
|
|
40
|
-
DefinitionResourcesProduct,
|
|
41
43
|
} from 'types/Joanie';
|
|
42
44
|
import { Payment, PaymentProviders } from 'components/PaymentInterfaces/types';
|
|
43
45
|
import { CourseStateFactory } from 'utils/test/factories/richie';
|
|
@@ -347,6 +349,16 @@ export const CourseListItemFactory = factory((): CourseListItem => {
|
|
|
347
349
|
};
|
|
348
350
|
});
|
|
349
351
|
|
|
352
|
+
export const PaymentInstallmentFactory = factory((): PaymentInstallment => {
|
|
353
|
+
return {
|
|
354
|
+
id: faker.string.uuid(),
|
|
355
|
+
currency: faker.finance.currencyCode(),
|
|
356
|
+
amount: faker.number.int(),
|
|
357
|
+
due_date: faker.date.future().toISOString(),
|
|
358
|
+
state: PaymentScheduleState.PAID,
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
|
|
350
362
|
export const OrderEnrollmentFactory = factory((): OrderEnrollment => {
|
|
351
363
|
return {
|
|
352
364
|
id: faker.string.uuid(),
|
|
@@ -415,6 +427,7 @@ export const CredentialOrderFactory = factory((): CredentialOrder => {
|
|
|
415
427
|
...AbstractOrderFactory().one(),
|
|
416
428
|
course: CourseLightFactory().one(),
|
|
417
429
|
enrollment: null,
|
|
430
|
+
payment_schedule: PaymentInstallmentFactory().many(3),
|
|
418
431
|
};
|
|
419
432
|
return order;
|
|
420
433
|
});
|
|
@@ -21,11 +21,19 @@ import {
|
|
|
21
21
|
CertificateFactory,
|
|
22
22
|
CourseLightFactory,
|
|
23
23
|
CourseRunFactory,
|
|
24
|
-
EnrollmentFactory,
|
|
25
24
|
CredentialOrderFactory,
|
|
25
|
+
CredentialOrderWithPaymentFactory,
|
|
26
|
+
EnrollmentFactory,
|
|
26
27
|
TargetCourseFactory,
|
|
27
28
|
} from 'utils/test/factories/joanie';
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
Certificate,
|
|
31
|
+
CourseLight,
|
|
32
|
+
CourseRun,
|
|
33
|
+
CredentialOrder,
|
|
34
|
+
OrderState,
|
|
35
|
+
PaymentScheduleState,
|
|
36
|
+
} from 'types/Joanie';
|
|
29
37
|
import { resolveAll } from 'utils/resolveAll';
|
|
30
38
|
import { confirm } from 'utils/indirection/window';
|
|
31
39
|
import { Priority } from 'types';
|
|
@@ -40,6 +48,7 @@ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
|
40
48
|
import { render } from 'utils/test/render';
|
|
41
49
|
import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
|
|
42
50
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
51
|
+
import { OrderHelper } from 'utils/OrderHelper';
|
|
43
52
|
import { DashboardTest } from '../../DashboardTest';
|
|
44
53
|
import { DashboardItemOrder } from './DashboardItemOrder';
|
|
45
54
|
|
|
@@ -53,8 +62,15 @@ jest.mock('utils/context', () => ({
|
|
|
53
62
|
|
|
54
63
|
jest.mock('utils/indirection/window', () => ({
|
|
55
64
|
confirm: jest.fn(() => true),
|
|
65
|
+
matchMedia: () => ({
|
|
66
|
+
matches: true,
|
|
67
|
+
addListener: jest.fn(),
|
|
68
|
+
removeListener: jest.fn(),
|
|
69
|
+
}),
|
|
56
70
|
}));
|
|
57
71
|
|
|
72
|
+
jest.mock('../../../../../components/PaymentInterfaces');
|
|
73
|
+
|
|
58
74
|
describe('<DashboardItemOrder/>', () => {
|
|
59
75
|
setupJoanieSession();
|
|
60
76
|
|
|
@@ -852,4 +868,96 @@ describe('<DashboardItemOrder/>', () => {
|
|
|
852
868
|
within(block).getByText(new RegExp(order.organization?.address?.postcode!));
|
|
853
869
|
within(block).getByText(new RegExp(order.organization?.address?.country!));
|
|
854
870
|
});
|
|
871
|
+
|
|
872
|
+
it('renders a writable order with failed payment and retry it successfully', async () => {
|
|
873
|
+
const { payment_info: paymentInfo, ...order } = CredentialOrderWithPaymentFactory().one();
|
|
874
|
+
|
|
875
|
+
const validOrder = { ...order };
|
|
876
|
+
validOrder.payment_schedule = [
|
|
877
|
+
{ ...order.payment_schedule![0] },
|
|
878
|
+
{ ...order.payment_schedule![1] },
|
|
879
|
+
{ ...order.payment_schedule![2] },
|
|
880
|
+
];
|
|
881
|
+
fetchMock
|
|
882
|
+
.post(
|
|
883
|
+
`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit_installment_payment/`,
|
|
884
|
+
paymentInfo,
|
|
885
|
+
)
|
|
886
|
+
.get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, validOrder);
|
|
887
|
+
|
|
888
|
+
order.state = OrderState.FAILED_PAYMENT;
|
|
889
|
+
order.payment_schedule![1].state = PaymentScheduleState.REFUSED;
|
|
890
|
+
|
|
891
|
+
const formatPrice = (price: number, currency: string) =>
|
|
892
|
+
new Intl.NumberFormat('en', {
|
|
893
|
+
currency,
|
|
894
|
+
style: 'currency',
|
|
895
|
+
}).format(price);
|
|
896
|
+
|
|
897
|
+
const { product } = mockCourseProductWithOrder(order);
|
|
898
|
+
fetchMock.get(
|
|
899
|
+
'https://joanie.endpoint/api/v1.0/orders/',
|
|
900
|
+
{ results: [order], next: null, previous: null, count: null },
|
|
901
|
+
{ overwriteRoutes: true },
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
render(
|
|
905
|
+
<DashboardTest initialRoute={LearnerDashboardPaths.ORDER.replace(':orderId', order.id)} />,
|
|
906
|
+
{ wrapper: BaseJoanieAppWrapper },
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
await screen.findByRole('heading', { level: 5, name: product.title });
|
|
910
|
+
screen.getByText(/a payment failed, please update your payment method/i);
|
|
911
|
+
const failedInstallment = OrderHelper.getFailedInstallment(order)!;
|
|
912
|
+
const button = screen.getByRole('button', {
|
|
913
|
+
name: 'Pay ' + formatPrice(failedInstallment.amount, failedInstallment.currency),
|
|
914
|
+
});
|
|
915
|
+
const user = userEvent.setup();
|
|
916
|
+
|
|
917
|
+
await user.click(button);
|
|
918
|
+
|
|
919
|
+
// Retry modal is shown.
|
|
920
|
+
screen.getByText('Retry payment');
|
|
921
|
+
screen.getByText(
|
|
922
|
+
/The payment failed, please choose another payment method or add a new one during the payment/,
|
|
923
|
+
);
|
|
924
|
+
screen.getByText('Use another credit card during payment');
|
|
925
|
+
|
|
926
|
+
// Prepare for cache invalidation.
|
|
927
|
+
fetchMock.get(
|
|
928
|
+
'https://joanie.endpoint/api/v1.0/orders/',
|
|
929
|
+
{ results: [validOrder], next: null, previous: null, count: null },
|
|
930
|
+
{ overwriteRoutes: true },
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
// Click on pay button.
|
|
934
|
+
const payButton = screen.getByTestId('order-payment-retry-modal-submit-button');
|
|
935
|
+
expect(payButton.innerHTML.replace(' ', ' ')).toEqual(
|
|
936
|
+
'Pay ' +
|
|
937
|
+
formatPrice(failedInstallment.amount, failedInstallment.currency).replace(
|
|
938
|
+
/(\u202F|\u00a0)/g,
|
|
939
|
+
' ',
|
|
940
|
+
),
|
|
941
|
+
);
|
|
942
|
+
await user.click(payButton);
|
|
943
|
+
// Pay via mocked payment interface
|
|
944
|
+
screen.getByText('Payment interface component');
|
|
945
|
+
await user.click(screen.getByTestId('payment-success'));
|
|
946
|
+
|
|
947
|
+
// Make sure retry modal is closed.
|
|
948
|
+
expect(screen.queryByText('Retry payment')).not.toBeInTheDocument();
|
|
949
|
+
|
|
950
|
+
// Success modal is shown, close it.
|
|
951
|
+
screen.getByText('Payment successful');
|
|
952
|
+
screen.getByText('The payment was successful');
|
|
953
|
+
const okButton = screen.getByRole('button', { name: 'Ok' });
|
|
954
|
+
await user.click(okButton);
|
|
955
|
+
|
|
956
|
+
// Warning alert is not shown anymore.
|
|
957
|
+
await waitFor(() => {
|
|
958
|
+
expect(
|
|
959
|
+
screen.queryByText(/a payment failed, please update your payment method/i),
|
|
960
|
+
).not.toBeInTheDocument();
|
|
961
|
+
});
|
|
962
|
+
});
|
|
855
963
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { FormattedMessage, useIntl
|
|
2
|
-
import { Button } from '@openfun/cunningham-react';
|
|
1
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
|
+
import { Alert, Button, useModal, VariantType } from '@openfun/cunningham-react';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
4
|
import { generatePath } from 'react-router-dom';
|
|
5
5
|
import { CourseLight, CredentialOrder, Product } from 'types/Joanie';
|
|
@@ -16,6 +16,8 @@ import ContractStatus from 'components/ContractStatus';
|
|
|
16
16
|
import SignContractButton from 'components/SignContractButton';
|
|
17
17
|
import { AddressView } from 'components/Address';
|
|
18
18
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
19
|
+
import { OrderPaymentDetailsModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal';
|
|
20
|
+
import { OrderPaymentRetryModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal';
|
|
19
21
|
import { DashboardSubItemsList } from '../DashboardSubItemsList';
|
|
20
22
|
import { DashboardItemCourseEnrolling } from '../CourseEnrolling';
|
|
21
23
|
import { DashboardItem } from '../index';
|
|
@@ -78,6 +80,31 @@ const messages = defineMessages({
|
|
|
78
80
|
description: 'Label for the organization DPO contact',
|
|
79
81
|
defaultMessage: 'Data protection email',
|
|
80
82
|
},
|
|
83
|
+
paymentTitle: {
|
|
84
|
+
id: 'components.DashboardItemOrder.paymentTitle',
|
|
85
|
+
description: 'Label for the payment block',
|
|
86
|
+
defaultMessage: 'Payment',
|
|
87
|
+
},
|
|
88
|
+
paymentLabel: {
|
|
89
|
+
id: 'components.DashboardItemOrder.paymentLabel',
|
|
90
|
+
description: 'Label for the payment block',
|
|
91
|
+
defaultMessage: 'You can see and manage all installments.',
|
|
92
|
+
},
|
|
93
|
+
paymentButton: {
|
|
94
|
+
id: 'components.DashboardItemOrder.paymentButton',
|
|
95
|
+
description: 'Button label for the payment block',
|
|
96
|
+
defaultMessage: 'Manage payment',
|
|
97
|
+
},
|
|
98
|
+
paymentNeededMessage: {
|
|
99
|
+
id: 'components.DashboardItemOrder.paymentNeededMessage',
|
|
100
|
+
description: 'Message displayed when payment is needed',
|
|
101
|
+
defaultMessage: 'A payment failed, please update your payment method',
|
|
102
|
+
},
|
|
103
|
+
paymentNeededButton: {
|
|
104
|
+
id: 'components.DashboardItemOrder.paymentNeededButton',
|
|
105
|
+
description: 'Button label for the payment needed message',
|
|
106
|
+
defaultMessage: 'Pay {amount}',
|
|
107
|
+
},
|
|
81
108
|
});
|
|
82
109
|
|
|
83
110
|
interface DashboardItemOrderProps {
|
|
@@ -299,11 +326,72 @@ const OrganizationBlock = ({ order, product }: { order: CredentialOrder; product
|
|
|
299
326
|
</div>
|
|
300
327
|
</div>
|
|
301
328
|
)}
|
|
329
|
+
<Installment order={order} />
|
|
302
330
|
</div>
|
|
303
331
|
</div>
|
|
304
332
|
);
|
|
305
333
|
};
|
|
306
334
|
|
|
335
|
+
const Installment = ({ order }: { order: CredentialOrder }) => {
|
|
336
|
+
const modal = useModal();
|
|
337
|
+
const retryModal = useModal();
|
|
338
|
+
const failedInstallment = OrderHelper.getFailedInstallment(order);
|
|
339
|
+
const intl = useIntl();
|
|
340
|
+
|
|
341
|
+
const pay = async () => {
|
|
342
|
+
retryModal.open();
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<>
|
|
347
|
+
<div className="dashboard-splitted-card__item">
|
|
348
|
+
<div
|
|
349
|
+
className={classNames('dashboard-splitted-card__item__title', {
|
|
350
|
+
'dashboard-splitted-card__item__title--dot': !!failedInstallment,
|
|
351
|
+
})}
|
|
352
|
+
>
|
|
353
|
+
<span>
|
|
354
|
+
<FormattedMessage {...messages.paymentTitle} />
|
|
355
|
+
</span>
|
|
356
|
+
</div>
|
|
357
|
+
{failedInstallment && (
|
|
358
|
+
<Alert
|
|
359
|
+
className="mb-t"
|
|
360
|
+
type={VariantType.ERROR}
|
|
361
|
+
buttons={
|
|
362
|
+
<Button size="small" onClick={pay}>
|
|
363
|
+
<FormattedMessage
|
|
364
|
+
{...messages.paymentNeededButton}
|
|
365
|
+
values={{
|
|
366
|
+
amount: intl.formatNumber(failedInstallment.amount, {
|
|
367
|
+
style: 'currency',
|
|
368
|
+
currency: failedInstallment.currency,
|
|
369
|
+
}),
|
|
370
|
+
}}
|
|
371
|
+
/>
|
|
372
|
+
</Button>
|
|
373
|
+
}
|
|
374
|
+
>
|
|
375
|
+
<FormattedMessage {...messages.paymentNeededMessage} />
|
|
376
|
+
</Alert>
|
|
377
|
+
)}
|
|
378
|
+
<div className="dashboard-splitted-card__item__description">
|
|
379
|
+
<FormattedMessage {...messages.paymentLabel} />
|
|
380
|
+
</div>
|
|
381
|
+
<div className="dashboard-splitted-card__item__actions">
|
|
382
|
+
<Button size="small" color="secondary" onClick={modal.open}>
|
|
383
|
+
<FormattedMessage {...messages.paymentButton} />
|
|
384
|
+
</Button>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
<OrderPaymentDetailsModal {...modal} order={order} />
|
|
388
|
+
{failedInstallment && (
|
|
389
|
+
<OrderPaymentRetryModal {...retryModal} installment={failedInstallment} order={order} />
|
|
390
|
+
)}
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
};
|
|
394
|
+
|
|
307
395
|
const ContractItem = ({ product, order }: { order: CredentialOrder; product: Product }) => {
|
|
308
396
|
if (!product?.contract_definition) {
|
|
309
397
|
return;
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Alert,
|
|
3
|
+
Button,
|
|
4
|
+
Modal,
|
|
5
|
+
ModalProps,
|
|
6
|
+
ModalSize,
|
|
7
|
+
useModal,
|
|
8
|
+
VariantType,
|
|
9
|
+
} from '@openfun/cunningham-react';
|
|
10
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
|
|
13
|
+
import { CreditCard, Order } from 'types/Joanie';
|
|
14
|
+
import { CreditCardSelector } from 'components/CreditCardSelector';
|
|
15
|
+
import { OrderHelper } from 'utils/OrderHelper';
|
|
16
|
+
import { OrderPaymentRetryModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal';
|
|
17
|
+
|
|
18
|
+
const messages = defineMessages({
|
|
19
|
+
title: {
|
|
20
|
+
id: 'components.DashboardItemOrder.PaymentModal.title',
|
|
21
|
+
defaultMessage: 'Payment details',
|
|
22
|
+
description: 'Title of the payment modal',
|
|
23
|
+
},
|
|
24
|
+
scheduleTitle: {
|
|
25
|
+
id: 'components.DashboardItemOrder.PaymentModal.scheduleTitle',
|
|
26
|
+
defaultMessage: 'Repayment schedule',
|
|
27
|
+
description: 'Title of the payment schedule',
|
|
28
|
+
},
|
|
29
|
+
paymentMethodTitle: {
|
|
30
|
+
id: 'components.DashboardItemOrder.PaymentModal.paymentMethodTitle',
|
|
31
|
+
defaultMessage: 'Payment method',
|
|
32
|
+
description: 'Title of the payment method section',
|
|
33
|
+
},
|
|
34
|
+
paymentNeededMessage: {
|
|
35
|
+
id: 'components.DashboardItemOrder.paymentNeededMessage',
|
|
36
|
+
description: 'Message displayed when payment is needed',
|
|
37
|
+
defaultMessage: 'The payment failed, please update your payment method',
|
|
38
|
+
},
|
|
39
|
+
paymentNeededButton: {
|
|
40
|
+
id: 'components.DashboardItemOrder.paymentNeededButton',
|
|
41
|
+
description: 'Button label for the payment needed message',
|
|
42
|
+
defaultMessage: 'Pay {amount}',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
interface PaymentModalProps extends Pick<ModalProps, 'isOpen' | 'onClose'> {
|
|
47
|
+
order: Order;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const OrderPaymentDetailsModal = ({ order, ...props }: PaymentModalProps) => {
|
|
51
|
+
const intl = useIntl();
|
|
52
|
+
const retryModal = useModal();
|
|
53
|
+
const failedInstallment = OrderHelper.getFailedInstallment(order);
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<Modal {...props} size={ModalSize.MEDIUM} title={intl.formatMessage(messages.title)}>
|
|
57
|
+
<h3 className="order-payment-details__title mb-s">
|
|
58
|
+
<FormattedMessage {...messages.paymentMethodTitle} />
|
|
59
|
+
</h3>
|
|
60
|
+
<CreditCardSelectorWrapper />
|
|
61
|
+
<h3 className="order-payment-details__title mb-s mt-b">
|
|
62
|
+
<FormattedMessage {...messages.scheduleTitle} />
|
|
63
|
+
</h3>
|
|
64
|
+
{failedInstallment && (
|
|
65
|
+
<Alert
|
|
66
|
+
className="mb-t"
|
|
67
|
+
type={VariantType.ERROR}
|
|
68
|
+
buttons={
|
|
69
|
+
<Button size="small" onClick={retryModal.open}>
|
|
70
|
+
<FormattedMessage
|
|
71
|
+
{...messages.paymentNeededButton}
|
|
72
|
+
values={{
|
|
73
|
+
amount: intl.formatNumber(failedInstallment.amount, {
|
|
74
|
+
style: 'currency',
|
|
75
|
+
currency: failedInstallment.currency,
|
|
76
|
+
}),
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
</Button>
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<FormattedMessage {...messages.paymentNeededMessage} />
|
|
83
|
+
</Alert>
|
|
84
|
+
)}
|
|
85
|
+
{order.payment_schedule && <PaymentScheduleGrid schedule={order.payment_schedule} />}
|
|
86
|
+
</Modal>
|
|
87
|
+
{failedInstallment && (
|
|
88
|
+
<OrderPaymentRetryModal {...retryModal} installment={failedInstallment} order={order} />
|
|
89
|
+
)}
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const CreditCardSelectorWrapper = () => {
|
|
95
|
+
// TODO: At the moment is automatically selects the default credit card but it must select the credit card used to
|
|
96
|
+
// buy the order.
|
|
97
|
+
const [creditCard, setCreditCard] = useState<CreditCard>();
|
|
98
|
+
return (
|
|
99
|
+
<CreditCardSelector
|
|
100
|
+
creditCard={creditCard}
|
|
101
|
+
setCreditCard={setCreditCard}
|
|
102
|
+
quickRemove={false}
|
|
103
|
+
allowEdit={false}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Alert,
|
|
3
|
+
Button,
|
|
4
|
+
Modal,
|
|
5
|
+
ModalProps,
|
|
6
|
+
ModalSize,
|
|
7
|
+
useModals,
|
|
8
|
+
VariantType,
|
|
9
|
+
} from '@openfun/cunningham-react';
|
|
10
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
11
|
+
import { useRef, useState } from 'react';
|
|
12
|
+
import { CreditCard, Order, PaymentInstallment, OrderState } from 'types/Joanie';
|
|
13
|
+
import { CreditCardSelector } from 'components/CreditCardSelector';
|
|
14
|
+
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
15
|
+
import { Payment, PaymentErrorMessageId } from 'components/PaymentInterfaces/types';
|
|
16
|
+
import PaymentInterface from 'components/PaymentInterfaces';
|
|
17
|
+
import { PAYMENT_SETTINGS } from 'settings';
|
|
18
|
+
import { useOrders } from 'hooks/useOrders';
|
|
19
|
+
import { Spinner } from 'components/Spinner';
|
|
20
|
+
|
|
21
|
+
const messages = defineMessages({
|
|
22
|
+
title: {
|
|
23
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.title',
|
|
24
|
+
defaultMessage: 'Retry payment',
|
|
25
|
+
description: 'Title of the payment retry modal',
|
|
26
|
+
},
|
|
27
|
+
description: {
|
|
28
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.description',
|
|
29
|
+
defaultMessage:
|
|
30
|
+
'The payment failed, please choose another payment method or add a new one during the payment',
|
|
31
|
+
description: 'Message displayed when payment is needed',
|
|
32
|
+
},
|
|
33
|
+
submit: {
|
|
34
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.submit',
|
|
35
|
+
defaultMessage: 'Pay {amount}',
|
|
36
|
+
description: 'Message displayed when payment is needed',
|
|
37
|
+
},
|
|
38
|
+
paymentInProgress: {
|
|
39
|
+
defaultMessage: 'Payment in progress',
|
|
40
|
+
description: 'Label for screen reader when a retry payment is in progress.',
|
|
41
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.paymentInProgress',
|
|
42
|
+
},
|
|
43
|
+
successTitle: {
|
|
44
|
+
defaultMessage: 'Payment successful',
|
|
45
|
+
description: 'Title of the payment success modal',
|
|
46
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.successTitle',
|
|
47
|
+
},
|
|
48
|
+
successDescription: {
|
|
49
|
+
defaultMessage: 'The payment was successful',
|
|
50
|
+
description: 'Description of the payment success modal',
|
|
51
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.successDescription',
|
|
52
|
+
},
|
|
53
|
+
errorFailedSubmitInstallmentPayment: {
|
|
54
|
+
defaultMessage: 'Failed to submit installment payment, please retry later.',
|
|
55
|
+
description: 'Error message when submitting installment payment fails',
|
|
56
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.errorFailedSubmitInstallmentPayment',
|
|
57
|
+
},
|
|
58
|
+
errorAbortingPolling: {
|
|
59
|
+
defaultMessage:
|
|
60
|
+
'Your payment has succeeded but your order validation is taking too long, you can close this dialog and come back later.',
|
|
61
|
+
description: 'Error message when submitting installment payment fails',
|
|
62
|
+
id: 'components.DashboardItemOrder.OrderPaymentRetryModal.errorAbortingPolling',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
interface Props extends Pick<ModalProps, 'isOpen' | 'onClose'> {
|
|
67
|
+
installment: PaymentInstallment;
|
|
68
|
+
order: Order;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
enum ComponentStates {
|
|
72
|
+
IDLE = 'idle',
|
|
73
|
+
LOADING = 'loading',
|
|
74
|
+
ERROR = 'error',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const OrderPaymentRetryModal = ({ installment, order, ...props }: Props) => {
|
|
78
|
+
const intl = useIntl();
|
|
79
|
+
const API = useJoanieApi();
|
|
80
|
+
const timeoutRef = useRef<NodeJS.Timeout>();
|
|
81
|
+
const { methods: orderMethods } = useOrders(undefined, { enabled: false });
|
|
82
|
+
const [payment, setPayment] = useState<Payment>();
|
|
83
|
+
const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
|
|
84
|
+
const [error, setError] = useState<string>();
|
|
85
|
+
const [creditCard, setCreditCard] = useState<CreditCard>();
|
|
86
|
+
const modals = useModals();
|
|
87
|
+
|
|
88
|
+
const pay = async () => {
|
|
89
|
+
setState(ComponentStates.LOADING);
|
|
90
|
+
try {
|
|
91
|
+
const paymentResponse = await API.user.orders.submit_installment_payment(order.id, {
|
|
92
|
+
credit_card_id: creditCard?.id,
|
|
93
|
+
});
|
|
94
|
+
if (paymentResponse) {
|
|
95
|
+
setPayment(paymentResponse);
|
|
96
|
+
} else {
|
|
97
|
+
// In case of bug.
|
|
98
|
+
setError(intl.formatMessage(messages.errorFailedSubmitInstallmentPayment));
|
|
99
|
+
setState(ComponentStates.ERROR);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
setError(intl.formatMessage(messages.errorFailedSubmitInstallmentPayment));
|
|
103
|
+
setState(ComponentStates.ERROR);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleError = (messageId: string = PaymentErrorMessageId.ERROR_DEFAULT) => {
|
|
108
|
+
setState(ComponentStates.ERROR);
|
|
109
|
+
setError(messageId);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const isOrderValidated = async (id: string): Promise<Boolean> => {
|
|
113
|
+
const orderToCheck = await API.user.orders.get({ id });
|
|
114
|
+
return (
|
|
115
|
+
orderToCheck?.state === OrderState.VALIDATED ||
|
|
116
|
+
orderToCheck?.state === OrderState.PENDING_PAYMENT
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const settled = async () => {
|
|
121
|
+
await orderMethods.invalidate();
|
|
122
|
+
props.onClose();
|
|
123
|
+
await modals.messageModal({
|
|
124
|
+
messageType: VariantType.SUCCESS,
|
|
125
|
+
title: intl.formatMessage(messages.successTitle),
|
|
126
|
+
children: intl.formatMessage(messages.successDescription),
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSuccess = () => {
|
|
131
|
+
let round = 0;
|
|
132
|
+
|
|
133
|
+
const checkOrderValidity = async () => {
|
|
134
|
+
if (round >= PAYMENT_SETTINGS.pollLimit) {
|
|
135
|
+
timeoutRef.current = undefined;
|
|
136
|
+
setState(ComponentStates.ERROR);
|
|
137
|
+
setError(intl.formatMessage(messages.errorAbortingPolling));
|
|
138
|
+
} else {
|
|
139
|
+
const isValidated = await isOrderValidated(order.id);
|
|
140
|
+
if (isValidated) {
|
|
141
|
+
setState(ComponentStates.IDLE);
|
|
142
|
+
timeoutRef.current = undefined;
|
|
143
|
+
settled();
|
|
144
|
+
} else {
|
|
145
|
+
round++;
|
|
146
|
+
timeoutRef.current = setTimeout(checkOrderValidity, PAYMENT_SETTINGS.pollInterval);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
checkOrderValidity();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<>
|
|
156
|
+
<Modal
|
|
157
|
+
{...props}
|
|
158
|
+
size={ModalSize.MEDIUM}
|
|
159
|
+
title={intl.formatMessage(messages.title)}
|
|
160
|
+
closeOnEsc={state !== ComponentStates.LOADING}
|
|
161
|
+
preventClose={state === ComponentStates.LOADING}
|
|
162
|
+
hideCloseButton={state === ComponentStates.LOADING}
|
|
163
|
+
actions={
|
|
164
|
+
<Button
|
|
165
|
+
color="primary"
|
|
166
|
+
size="small"
|
|
167
|
+
fullWidth={true}
|
|
168
|
+
onClick={pay}
|
|
169
|
+
disabled={state === ComponentStates.LOADING}
|
|
170
|
+
data-testid="order-payment-retry-modal-submit-button"
|
|
171
|
+
>
|
|
172
|
+
{state === ComponentStates.LOADING ? (
|
|
173
|
+
<Spinner theme="light" aria-labelledby="payment-in-progress">
|
|
174
|
+
<span id="payment-in-progress">
|
|
175
|
+
<FormattedMessage {...messages.paymentInProgress} />
|
|
176
|
+
</span>
|
|
177
|
+
</Spinner>
|
|
178
|
+
) : (
|
|
179
|
+
<FormattedMessage
|
|
180
|
+
{...messages.submit}
|
|
181
|
+
values={{
|
|
182
|
+
amount: intl.formatNumber(installment.amount, {
|
|
183
|
+
style: 'currency',
|
|
184
|
+
currency: installment.currency,
|
|
185
|
+
}),
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
</Button>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
{error && (
|
|
193
|
+
<Alert type={VariantType.ERROR} className="mb-t">
|
|
194
|
+
{error}
|
|
195
|
+
</Alert>
|
|
196
|
+
)}
|
|
197
|
+
<Alert className="mb-b">
|
|
198
|
+
<FormattedMessage {...messages.description} />
|
|
199
|
+
</Alert>
|
|
200
|
+
<CreditCardSelector
|
|
201
|
+
creditCard={creditCard}
|
|
202
|
+
setCreditCard={setCreditCard}
|
|
203
|
+
quickRemove={state !== ComponentStates.LOADING}
|
|
204
|
+
allowEdit={state !== ComponentStates.LOADING}
|
|
205
|
+
/>
|
|
206
|
+
</Modal>
|
|
207
|
+
{state === ComponentStates.LOADING && payment && (
|
|
208
|
+
<PaymentInterface {...payment} onError={handleError} onSuccess={handleSuccess} />
|
|
209
|
+
)}
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
};
|