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.
Files changed (31) hide show
  1. package/js/api/joanie.ts +30 -1
  2. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
  3. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
  4. package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
  5. package/js/components/PaymentScheduleGrid/index.tsx +50 -70
  6. package/js/components/PurchaseButton/index.spec.tsx +27 -12
  7. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
  8. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
  9. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
  10. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
  11. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
  12. package/js/components/SaleTunnel/_styles.scss +6 -4
  13. package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
  14. package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
  15. package/js/components/SaleTunnel/index.spec.tsx +127 -61
  16. package/js/hooks/usePaymentSchedule.tsx +23 -0
  17. package/js/hooks/useResources/useResourcesRoot.ts +3 -3
  18. package/js/index.tsx +2 -0
  19. package/js/types/Joanie.ts +31 -0
  20. package/js/utils/OrderHelper/index.ts +13 -0
  21. package/js/utils/test/factories/joanie.ts +32 -19
  22. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
  23. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
  24. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
  25. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
  26. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
  27. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
  28. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
  29. package/package.json +2 -1
  30. package/scss/components/_index.scss +2 -1
  31. /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
- CourseListItem,
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 { Certificate, CourseLight, CourseRun, CredentialOrder, OrderState } from 'types/Joanie';
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('&nbsp;', ' ')).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, defineMessages } from 'react-intl';
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;
@@ -0,0 +1,7 @@
1
+ .order-payment-details {
2
+ &__title {
3
+ font-weight: var(--c--theme--font--weights--bold);
4
+ font-size: var(--c--theme--font--sizes--l);
5
+ text-align: left;
6
+ }
7
+ }
@@ -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
+ };