richie-education 2.28.2-dev26 → 2.28.2-dev53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/js/api/joanie.ts +42 -17
  2. package/js/api/lms/dummy.ts +1 -12
  3. package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +16 -9
  4. package/js/components/ContractFrame/AbstractContractFrame.tsx +28 -23
  5. package/js/components/ContractFrame/LearnerContractFrame.tsx +2 -2
  6. package/js/components/ContractFrame/_styles.scss +6 -14
  7. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +15 -45
  8. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +17 -24
  9. package/js/components/DownloadContractButton/index.spec.tsx +1 -1
  10. package/js/components/OpenEdxFullNameForm/index.spec.tsx +229 -0
  11. package/js/components/OpenEdxFullNameForm/index.tsx +7 -7
  12. package/js/components/PaymentInterfaces/LyraPopIn.tsx +2 -2
  13. package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
  14. package/js/components/PaymentInterfaces/__mocks__/index.tsx +1 -4
  15. package/js/components/PaymentInterfaces/types.ts +5 -2
  16. package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
  17. package/js/components/PaymentScheduleGrid/index.tsx +50 -70
  18. package/js/components/PurchaseButton/index.spec.tsx +84 -37
  19. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +2 -1
  20. package/js/components/SaleTunnel/CertificateSaleTunnel/index.tsx +2 -2
  21. package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +6 -10
  22. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +80 -27
  23. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +16 -20
  24. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/_styles.scss +12 -0
  25. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +160 -0
  26. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +15 -29
  27. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
  28. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +39 -11
  29. package/js/components/SaleTunnel/SubscriptionButton/_styles.scss +7 -0
  30. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +201 -0
  31. package/js/components/SaleTunnel/_styles.scss +16 -5
  32. package/js/components/SaleTunnel/hooks/useTerms.tsx +0 -77
  33. package/js/components/SaleTunnel/index.credential.spec.tsx +14 -25
  34. package/js/components/SaleTunnel/index.full-process.spec.tsx +116 -48
  35. package/js/components/SaleTunnel/index.spec.tsx +334 -717
  36. package/js/components/SignContractButton/index.omniscientOrders.spec.tsx +16 -11
  37. package/js/components/SignContractButton/index.spec.tsx +16 -20
  38. package/js/components/SignContractButton/index.tsx +3 -1
  39. package/js/hooks/useCreditCards/index.spec.tsx +70 -6
  40. package/js/hooks/useCreditCards/index.ts +49 -11
  41. package/js/hooks/useOrders/index.spec.tsx +322 -0
  42. package/js/hooks/{useOrders.ts → useOrders/index.ts} +40 -14
  43. package/js/hooks/usePaymentSchedule.tsx +23 -0
  44. package/js/hooks/useProductOrder/index.spec.tsx +77 -60
  45. package/js/hooks/useProductOrder/index.tsx +2 -2
  46. package/js/hooks/useResources/useResourcesRoot.ts +4 -3
  47. package/js/index.tsx +2 -0
  48. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.spec.tsx +1 -1
  49. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.tsx +4 -2
  50. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +8 -5
  51. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +8 -9
  52. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +1 -1
  53. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +1 -6
  54. package/js/settings/settings.test.ts +11 -2
  55. package/js/types/Joanie.ts +77 -31
  56. package/js/utils/OrderHelper/index.ts +47 -38
  57. package/js/utils/test/factories/joanie.ts +66 -68
  58. package/js/widgets/Dashboard/components/DashboardItem/CourseEnrolling/index.tsx +8 -18
  59. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +26 -32
  60. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +11 -6
  61. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +114 -5
  62. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +99 -12
  63. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +3 -1
  64. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +6 -7
  65. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
  66. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +126 -0
  67. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +209 -0
  68. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +18 -71
  69. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +40 -25
  70. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +28 -22
  71. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +18 -73
  72. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +32 -16
  73. package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +3 -11
  74. package/js/widgets/Dashboard/components/Signature/SignatureDummy.tsx +25 -3
  75. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/EnrollableCourseRunList.tsx +2 -6
  76. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +7 -14
  77. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.spec.tsx +7 -5
  78. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.tsx +5 -7
  79. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +242 -332
  80. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +12 -13
  81. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +10 -21
  82. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +2 -2
  83. package/package.json +2 -1
  84. package/scss/components/_index.scss +4 -2
  85. package/js/components/PaymentButton/_styles.scss +0 -27
  86. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +0 -338
  87. package/js/components/SaleTunnel/SaleTunnelNotValidated/index.tsx +0 -70
  88. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader/index.tsx +0 -41
  89. /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
@@ -9,7 +9,6 @@ import {
9
9
  CourseRun,
10
10
  CredentialOrder,
11
11
  Enrollment,
12
- Product,
13
12
  } from 'types/Joanie';
14
13
  import { Spinner } from 'components/Spinner';
15
14
  import Banner, { BannerType } from 'components/Banner';
@@ -68,10 +67,11 @@ const messages = defineMessages({
68
67
  description: 'Text displayed when course runs list is loading',
69
68
  id: 'components.DashboardItemCourseEnrollingRun.courseRunsLoading',
70
69
  },
71
- contractUnsigned: {
72
- id: 'components.DashboardItemCourseEnrollingRun.contractUnsigned',
73
- description: 'Message displayed as disabled button title when a contract needs to be signed.',
74
- defaultMessage: 'You have to sign the training contract before enrolling to your course.',
70
+ cannotEnroll: {
71
+ id: 'components.DashboardItemCourseEnrollingRun.cannotEnroll',
72
+ description:
73
+ 'Message displayed as disabled button title when the order state does not allow enrollment.',
74
+ defaultMessage: 'You cannot enroll yet to this training.',
75
75
  },
76
76
  });
77
77
 
@@ -81,7 +81,6 @@ interface DashboardItemCourseEnrollingProps {
81
81
  course: AbstractCourse;
82
82
  activeEnrollment?: Enrollment;
83
83
  order?: CredentialOrder;
84
- product?: Product;
85
84
  writable: boolean;
86
85
  hideEnrollButtons?: boolean;
87
86
  icon?: boolean;
@@ -93,7 +92,6 @@ export const DashboardItemCourseEnrolling = ({
93
92
  activeEnrollment,
94
93
  writable,
95
94
  order,
96
- product,
97
95
  icon = false,
98
96
  notEnrolledUrl = '#',
99
97
  hideEnrollButtons,
@@ -121,7 +119,6 @@ export const DashboardItemCourseEnrolling = ({
121
119
  course={course}
122
120
  enrollments={CoursesHelper.findCourseEnrollmentsInOrder(course, order)}
123
121
  order={order}
124
- product={product}
125
122
  />
126
123
  )}
127
124
  </div>
@@ -132,14 +129,12 @@ interface DashboardItemCourseEnrollingRunsProps {
132
129
  course: AbstractCourse;
133
130
  enrollments: Enrollment[];
134
131
  order?: CredentialOrder;
135
- product?: Product;
136
132
  }
137
133
 
138
134
  const DashboardItemCourseEnrollingRuns = ({
139
135
  course,
140
136
  enrollments,
141
137
  order,
142
- product,
143
138
  }: DashboardItemCourseEnrollingRunsProps) => {
144
139
  const { enroll, isLoading, error } = useEnroll(enrollments, order);
145
140
 
@@ -176,7 +171,6 @@ const DashboardItemCourseEnrollingRuns = ({
176
171
  selected={data.selected}
177
172
  enroll={() => enroll(data.courseRun)}
178
173
  order={order}
179
- product={product}
180
174
  />
181
175
  ))}
182
176
  {isLoading && (
@@ -200,7 +194,6 @@ interface DashboardItemCourseEnrollingRunProps {
200
194
  selected: boolean;
201
195
  enroll: () => void;
202
196
  order?: CredentialOrder | CertificateOrder;
203
- product?: Product;
204
197
  }
205
198
 
206
199
  export const DashboardItemCourseEnrollingRun = ({
@@ -208,14 +201,11 @@ export const DashboardItemCourseEnrollingRun = ({
208
201
  selected,
209
202
  enroll,
210
203
  order,
211
- product,
212
204
  }: DashboardItemCourseEnrollingRunProps) => {
213
205
  const intl = useIntl();
214
206
  const formatDate = useDateFormat();
215
207
  const courseRunPeriodMessage = useCourseRunPeriodMessage(courseRun, selected);
216
- const haveToSignContract = order
217
- ? OrderHelper.orderNeedsSignature(order, product?.contract_definition)
218
- : false;
208
+ const canEnroll = OrderHelper.allowEnrollment(order);
219
209
  const isOpenedForEnrollment = useMemo(
220
210
  () => courseRun.state.priority < Priority.FUTURE_NOT_YET_OPEN,
221
211
  [courseRun],
@@ -266,11 +256,11 @@ export const DashboardItemCourseEnrollingRun = ({
266
256
  ) : (
267
257
  <div>
268
258
  <Button
269
- disabled={!isOpenedForEnrollment || haveToSignContract}
259
+ disabled={!isOpenedForEnrollment || !canEnroll}
270
260
  color="tertiary"
271
261
  size="small"
272
262
  onClick={enroll}
273
- title={haveToSignContract ? intl.formatMessage(messages.contractUnsigned) : ''}
263
+ title={!canEnroll ? intl.formatMessage(messages.cannotEnroll) : ''}
274
264
  >
275
265
  <FormattedMessage {...messages.enrollRun} />
276
266
  </Button>
@@ -1,7 +1,13 @@
1
1
  import { screen, waitForElementToBeRemoved } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
  import userEvent from '@testing-library/user-event';
4
- import { CertificateProduct, CourseLight, OrderState, ProductType } from 'types/Joanie';
4
+ import {
5
+ CertificateProduct,
6
+ CourseLight,
7
+ OrderState,
8
+ ProductType,
9
+ PURCHASABLE_ORDER_STATES,
10
+ } from 'types/Joanie';
5
11
  import {
6
12
  CourseStateFactory,
7
13
  RichieContextFactory as mockRichieContextFactory,
@@ -43,7 +49,7 @@ jest.mock('components/SaleTunnel', () => ({
43
49
  return;
44
50
  }
45
51
  setTimeout(() => {
46
- const order = Factories.CertificateOrderWithOneClickPaymentFactory().one();
52
+ const order = Factories.CertificateOrderFactory().one();
47
53
  onFinish?.(order);
48
54
  }, 100);
49
55
  }, [isOpen]);
@@ -135,7 +141,7 @@ describe('<ProductCertificateFooter/>', () => {
135
141
  it('should display download button for a course run with certificate.', () => {
136
142
  const order = OrderEnrollmentFactory({
137
143
  certificate_id: 'FAKE_CERTIFICATE_ID',
138
- state: OrderState.VALIDATED,
144
+ state: OrderState.COMPLETED,
139
145
  product_id: product.id,
140
146
  }).one();
141
147
  const enrollment = EnrollmentFactory({
@@ -151,35 +157,23 @@ describe('<ProductCertificateFooter/>', () => {
151
157
  expect(screen.queryByTestId('PurchaseButton__cta')).not.toBeInTheDocument();
152
158
  });
153
159
 
154
- it('should not display purchase button for a course run with submitted order.', () => {
155
- const order = OrderEnrollmentFactory({
156
- certificate_id: undefined,
157
- product_id: product.id,
158
- state: OrderState.SUBMITTED,
159
- }).one();
160
- const enrollment = EnrollmentFactory({
161
- orders: [order],
162
- course_run: CourseRunFactory({ course }).one(),
163
- }).one();
164
- render(<ProductCertificateFooter product={product} enrollment={enrollment} />);
165
- expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
166
- expect(screen.queryByTestId('PurchaseButton__cta')).not.toBeInTheDocument();
167
- });
168
-
169
- it('should display purchase button for a course run with pending order.', () => {
170
- const order = OrderEnrollmentFactory({
171
- certificate_id: undefined,
172
- product_id: product.id,
173
- state: OrderState.PENDING,
174
- }).one();
175
- const enrollment = EnrollmentFactory({
176
- orders: [order],
177
- course_run: CourseRunFactory({ course }).one(),
178
- }).one();
179
- render(<ProductCertificateFooter product={product} enrollment={enrollment} />);
180
- expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
181
- expect(screen.getByTestId('PurchaseButton__cta')).toBeInTheDocument();
182
- });
160
+ it.each(PURCHASABLE_ORDER_STATES)(
161
+ 'should display purchase button for a course run with %s order.',
162
+ (state) => {
163
+ const order = OrderEnrollmentFactory({
164
+ certificate_id: undefined,
165
+ product_id: product.id,
166
+ state,
167
+ }).one();
168
+ const enrollment = EnrollmentFactory({
169
+ orders: [order],
170
+ course_run: CourseRunFactory({ course }).one(),
171
+ }).one();
172
+ render(<ProductCertificateFooter product={product} enrollment={enrollment} />);
173
+ expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
174
+ expect(screen.getByTestId('PurchaseButton__cta')).toBeInTheDocument();
175
+ },
176
+ );
183
177
 
184
178
  it('should not display button (download or purchase) for a course run with order but without certificate.', () => {
185
179
  const order = OrderEnrollmentFactory({
@@ -2,7 +2,12 @@ import { FormattedMessage, defineMessages } from 'react-intl';
2
2
  import { useState } from 'react';
3
3
  import PurchaseButton from 'components/PurchaseButton';
4
4
  import { Icon, IconTypeEnum } from 'components/Icon';
5
- import { CertificateProduct, Enrollment, OrderState, ProductType } from 'types/Joanie';
5
+ import {
6
+ CertificateProduct,
7
+ Enrollment,
8
+ ProductType,
9
+ PURCHASABLE_ORDER_STATES,
10
+ } from 'types/Joanie';
6
11
  import DownloadCertificateButton from 'components/DownloadCertificateButton';
7
12
  import { useCertificate } from 'hooks/useCertificates';
8
13
  import { isOpenedCourseRunCertificate } from 'utils/CourseRuns';
@@ -51,7 +56,7 @@ const ProductCertificateFooter = ({ product, enrollment }: ProductCertificateFoo
51
56
  <div className="dashboard-item__course-enrolling__infos">
52
57
  <div className="dashboard-item__block__status">
53
58
  <Icon name={IconTypeEnum.CERTIFICATE} />
54
- {order?.state === OrderState.VALIDATED ? (
59
+ {OrderHelper.isActive(order) ? (
55
60
  <>
56
61
  {product.certificate_definition.title + '. '}
57
62
  <CertificateStatus certificate={certificate} productType={product.type} />
@@ -60,11 +65,11 @@ const ProductCertificateFooter = ({ product, enrollment }: ProductCertificateFoo
60
65
  <FormattedMessage {...messages.buyProductCertificateLabel} />
61
66
  )}
62
67
  </div>
63
- {order?.state === OrderState.VALIDATED ? (
64
- order.certificate_id && (
68
+ {OrderHelper.isActive(order) ? (
69
+ order!.certificate_id && (
65
70
  <DownloadCertificateButton
66
71
  className="dashboard-item__button"
67
- certificateId={order.certificate_id}
72
+ certificateId={order!.certificate_id}
68
73
  />
69
74
  )
70
75
  ) : (
@@ -73,7 +78,7 @@ const ProductCertificateFooter = ({ product, enrollment }: ProductCertificateFoo
73
78
  product={product}
74
79
  enrollment={enrollment}
75
80
  buttonProps={{ size: 'small' }}
76
- disabled={order?.state === OrderState.SUBMITTED}
81
+ disabled={order && !PURCHASABLE_ORDER_STATES.includes(order.state)}
77
82
  onFinish={(o) => {
78
83
  /**
79
84
  * As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
@@ -21,11 +21,19 @@ import {
21
21
  CertificateFactory,
22
22
  CourseLightFactory,
23
23
  CourseRunFactory,
24
- EnrollmentFactory,
25
24
  CredentialOrderFactory,
25
+ EnrollmentFactory,
26
+ PaymentFactory,
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
 
@@ -75,7 +91,7 @@ describe('<DashboardItemOrder/>', () => {
75
91
 
76
92
  await screen.findByRole('heading', { level: 5, name: product.title });
77
93
  await screen.findByText('Ref. ' + (order.course as CourseLight).code);
78
- await screen.findByText('Pending');
94
+ await screen.findByText('Pending for the first direct debit');
79
95
  await screen.findByRole('link', { name: 'View details' });
80
96
  });
81
97
 
@@ -103,7 +119,7 @@ describe('<DashboardItemOrder/>', () => {
103
119
 
104
120
  await screen.findByRole('heading', { level: 5, name: product.title });
105
121
  await screen.findByText('Ref. ' + (order.course as CourseLight).code);
106
- await screen.findByText('Completed');
122
+ await screen.findByText('Successfully completed');
107
123
  await screen.findByRole('link', { name: 'View details' });
108
124
  await expectSpinner('Loading certificate...');
109
125
  deferred.resolve(certificate);
@@ -122,7 +138,7 @@ describe('<DashboardItemOrder/>', () => {
122
138
 
123
139
  await screen.findByRole('heading', { level: 5, name: product.title });
124
140
  await screen.findByText('Ref. ' + (order.course as CourseLight).code);
125
- await screen.findByText('Completed');
141
+ await screen.findByText('Successfully completed');
126
142
  await screen.findByRole('link', { name: 'View details' });
127
143
  await expectNoSpinner('Loading certificate ...');
128
144
  });
@@ -852,4 +868,97 @@ 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 order = CredentialOrderFactory().one();
874
+ const paymentInfo = PaymentFactory().one();
875
+
876
+ const validOrder = { ...order };
877
+ validOrder.payment_schedule = [
878
+ { ...order.payment_schedule![0] },
879
+ { ...order.payment_schedule![1] },
880
+ { ...order.payment_schedule![2] },
881
+ ];
882
+ fetchMock
883
+ .post(
884
+ `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit_installment_payment/`,
885
+ paymentInfo,
886
+ )
887
+ .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, validOrder);
888
+
889
+ order.state = OrderState.FAILED_PAYMENT;
890
+ order.payment_schedule![1].state = PaymentScheduleState.REFUSED;
891
+
892
+ const formatPrice = (price: number, currency: string) =>
893
+ new Intl.NumberFormat('en', {
894
+ currency,
895
+ style: 'currency',
896
+ }).format(price);
897
+
898
+ const { product } = mockCourseProductWithOrder(order);
899
+ fetchMock.get(
900
+ 'https://joanie.endpoint/api/v1.0/orders/',
901
+ { results: [order], next: null, previous: null, count: null },
902
+ { overwriteRoutes: true },
903
+ );
904
+
905
+ render(
906
+ <DashboardTest initialRoute={LearnerDashboardPaths.ORDER.replace(':orderId', order.id)} />,
907
+ { wrapper: BaseJoanieAppWrapper },
908
+ );
909
+
910
+ await screen.findByRole('heading', { level: 5, name: product.title });
911
+ screen.getByText(/a payment failed, please update your payment method/i);
912
+ const failedInstallment = OrderHelper.getFailedInstallment(order)!;
913
+ const button = screen.getByRole('button', {
914
+ name: 'Pay ' + formatPrice(failedInstallment.amount, failedInstallment.currency),
915
+ });
916
+ const user = userEvent.setup();
917
+
918
+ await user.click(button);
919
+
920
+ // Retry modal is shown.
921
+ screen.getByText('Retry payment');
922
+ screen.getByText(
923
+ /The payment failed, please choose another payment method or add a new one during the payment/,
924
+ );
925
+ screen.getByText('Use another credit card');
926
+
927
+ // Prepare for cache invalidation.
928
+ fetchMock.get(
929
+ 'https://joanie.endpoint/api/v1.0/orders/',
930
+ { results: [validOrder], next: null, previous: null, count: null },
931
+ { overwriteRoutes: true },
932
+ );
933
+
934
+ // Click on pay button.
935
+ const payButton = screen.getByTestId('order-payment-retry-modal-submit-button');
936
+ expect(payButton.innerHTML.replace('&nbsp;', ' ')).toEqual(
937
+ 'Pay ' +
938
+ formatPrice(failedInstallment.amount, failedInstallment.currency).replace(
939
+ /(\u202F|\u00a0)/g,
940
+ ' ',
941
+ ),
942
+ );
943
+ await user.click(payButton);
944
+ // Pay via mocked payment interface
945
+ screen.getByText('Payment interface component');
946
+ await user.click(screen.getByTestId('payment-success'));
947
+
948
+ // Make sure retry modal is closed.
949
+ expect(screen.queryByText('Retry payment')).not.toBeInTheDocument();
950
+
951
+ // Success modal is shown, close it.
952
+ screen.getByText('Payment successful');
953
+ screen.getByText('The payment was successful');
954
+ const okButton = screen.getByRole('button', { name: 'Ok' });
955
+ await user.click(okButton);
956
+
957
+ // Warning alert is not shown anymore.
958
+ await waitFor(() => {
959
+ expect(
960
+ screen.queryByText(/a payment failed, please update your payment method/i),
961
+ ).not.toBeInTheDocument();
962
+ });
963
+ });
855
964
  });
@@ -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 {
@@ -137,13 +164,16 @@ export const DashboardItemOrder = ({
137
164
  course_id: course.code,
138
165
  });
139
166
  const { product } = courseProductRelation || {};
140
- const needsSignature = OrderHelper.orderNeedsSignature(order, product?.contract_definition);
167
+ const needsSignature = OrderHelper.orderNeedsSignature(order);
168
+ const canEnroll = OrderHelper.allowEnrollment(order);
169
+
170
+ if (!product) return null;
141
171
 
142
172
  return (
143
173
  <div className="dashboard-item-order">
144
174
  <DashboardItem
145
175
  data-testid={`dashboard-item-order-${order.id}`}
146
- title={product?.title ?? ''}
176
+ title={product.title}
147
177
  code={'Ref. ' + course.code}
148
178
  imageUrl={course.cover?.src}
149
179
  more={
@@ -158,10 +188,7 @@ export const DashboardItemOrder = ({
158
188
  <div className="dashboard-item-order__footer">
159
189
  <div className="dashboard-item__block__status">
160
190
  <Icon name={IconTypeEnum.SCHOOL} />
161
- <OrderStateLearnerMessage
162
- order={order}
163
- contractDefinition={product?.contract_definition}
164
- />
191
+ <OrderStateLearnerMessage order={order} />
165
192
  </div>
166
193
  {showDetailsButton && (
167
194
  <RouterButton
@@ -179,7 +206,7 @@ export const DashboardItemOrder = ({
179
206
  key={`DashboardItemOrderContract_${order.id}`}
180
207
  title={product.title}
181
208
  order={order}
182
- contract_definition={product?.contract_definition!}
209
+ contract_definition={product.contract_definition!}
183
210
  contract={order.contract}
184
211
  writable={writable}
185
212
  mode="compact"
@@ -197,7 +224,6 @@ export const DashboardItemOrder = ({
197
224
  writable={writable}
198
225
  course={targetCourse}
199
226
  order={order}
200
- product={product}
201
227
  activeEnrollment={CoursesHelper.findActiveCourseEnrollmentInOrder(
202
228
  targetCourse,
203
229
  order,
@@ -205,7 +231,7 @@ export const DashboardItemOrder = ({
205
231
  notEnrolledUrl={generatePath(LearnerDashboardPaths.ORDER, {
206
232
  orderId: order.id,
207
233
  })}
208
- hideEnrollButtons={needsSignature}
234
+ hideEnrollButtons={!canEnroll}
209
235
  />
210
236
  }
211
237
  />
@@ -299,17 +325,78 @@ const OrganizationBlock = ({ order, product }: { order: CredentialOrder; product
299
325
  </div>
300
326
  </div>
301
327
  )}
328
+ <Installment order={order} />
302
329
  </div>
303
330
  </div>
304
331
  );
305
332
  };
306
333
 
334
+ const Installment = ({ order }: { order: CredentialOrder }) => {
335
+ const modal = useModal();
336
+ const retryModal = useModal();
337
+ const failedInstallment = OrderHelper.getFailedInstallment(order);
338
+ const intl = useIntl();
339
+
340
+ const pay = async () => {
341
+ retryModal.open();
342
+ };
343
+
344
+ return (
345
+ <>
346
+ <div className="dashboard-splitted-card__item">
347
+ <div
348
+ className={classNames('dashboard-splitted-card__item__title', {
349
+ 'dashboard-splitted-card__item__title--dot': !!failedInstallment,
350
+ })}
351
+ >
352
+ <span>
353
+ <FormattedMessage {...messages.paymentTitle} />
354
+ </span>
355
+ </div>
356
+ {failedInstallment && (
357
+ <Alert
358
+ className="mb-t"
359
+ type={VariantType.ERROR}
360
+ buttons={
361
+ <Button size="small" onClick={pay}>
362
+ <FormattedMessage
363
+ {...messages.paymentNeededButton}
364
+ values={{
365
+ amount: intl.formatNumber(failedInstallment.amount, {
366
+ style: 'currency',
367
+ currency: failedInstallment.currency,
368
+ }),
369
+ }}
370
+ />
371
+ </Button>
372
+ }
373
+ >
374
+ <FormattedMessage {...messages.paymentNeededMessage} />
375
+ </Alert>
376
+ )}
377
+ <div className="dashboard-splitted-card__item__description">
378
+ <FormattedMessage {...messages.paymentLabel} />
379
+ </div>
380
+ <div className="dashboard-splitted-card__item__actions">
381
+ <Button size="small" color="secondary" onClick={modal.open}>
382
+ <FormattedMessage {...messages.paymentButton} />
383
+ </Button>
384
+ </div>
385
+ </div>
386
+ <OrderPaymentDetailsModal {...modal} order={order} />
387
+ {failedInstallment && (
388
+ <OrderPaymentRetryModal {...retryModal} installment={failedInstallment} order={order} />
389
+ )}
390
+ </>
391
+ );
392
+ };
393
+
307
394
  const ContractItem = ({ product, order }: { order: CredentialOrder; product: Product }) => {
308
395
  if (!product?.contract_definition) {
309
396
  return;
310
397
  }
311
398
 
312
- const needsSignature = OrderHelper.orderNeedsSignature(order, product.contract_definition);
399
+ const needsSignature = OrderHelper.orderNeedsSignature(order);
313
400
  return (
314
401
  <div
315
402
  id={`dashboard-item-contract-${order.id}`}
@@ -3,7 +3,7 @@ import { faker } from '@faker-js/faker';
3
3
  import { screen } from '@testing-library/react';
4
4
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
5
  import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
6
- import { CourseLight } from 'types/Joanie';
6
+ import { CourseLight, OrderState } from 'types/Joanie';
7
7
  import {
8
8
  ContractDefinitionFactory,
9
9
  ContractFactory,
@@ -124,6 +124,7 @@ describe('<DashboardItemOrder/> Contract', () => {
124
124
 
125
125
  it('renders a non-writable order with a contract not signed yet', async () => {
126
126
  const order = CredentialOrderFactory({
127
+ state: OrderState.TO_SIGN,
127
128
  target_courses: TargetCourseFactory().many(1),
128
129
  target_enrollments: [],
129
130
  contract: ContractFactory({ student_signed_on: undefined }).one(),
@@ -186,6 +187,7 @@ describe('<DashboardItemOrder/> Contract', () => {
186
187
  });
187
188
  it('renders a writable order with a contract not signed yet', async () => {
188
189
  const order = CredentialOrderFactory({
190
+ state: OrderState.TO_SIGN,
189
191
  target_courses: TargetCourseFactory().many(1),
190
192
  target_enrollments: [],
191
193
  contract: null,
@@ -19,6 +19,7 @@ import { render } from 'utils/test/render';
19
19
  import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
20
20
 
21
21
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
22
+ import { OrderState } from 'types/Joanie';
22
23
 
23
24
  jest.mock('utils/context', () => ({
24
25
  __esModule: true,
@@ -49,9 +50,10 @@ describe('<DashboardItemOrder/> Contract', () => {
49
50
  describe('writable', () => {
50
51
  it('successfully sign a contract', async () => {
51
52
  const order = CredentialOrderFactory({
53
+ state: OrderState.TO_SIGN,
52
54
  target_courses: TargetCourseFactory().many(1),
53
55
  target_enrollments: [],
54
- contract: ContractFactory({ student_signed_on: undefined }).one(),
56
+ contract: ContractFactory({ student_signed_on: null }).one(),
55
57
  }).one();
56
58
 
57
59
  // learner dashboard course page do one call to course product relation per order
@@ -82,6 +84,7 @@ describe('<DashboardItemOrder/> Contract', () => {
82
84
  `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit_for_signature/`,
83
85
  submitDeferred.promise,
84
86
  );
87
+ fetchMock.post(`https://joanie.endpoint/api/v1.0/signature/notifications/`, 200);
85
88
 
86
89
  // delay: null is needed because as we are using fake timers it would mock the timers of
87
90
  // RTL too. See https://github.com/testing-library/user-event/issues/833.
@@ -92,11 +95,7 @@ describe('<DashboardItemOrder/> Contract', () => {
92
95
  });
93
96
 
94
97
  await expectNoSpinner('Loading orders and enrollments...');
95
-
96
- expect(
97
- await screen.findByRole('heading', { level: 5, name: product.title }),
98
- ).toBeInTheDocument();
99
-
98
+ await screen.findByRole('heading', { level: 5, name: product.title });
100
99
  // Make sure the sign button is shown.
101
100
  await user.click(screen.getByRole('link', { name: 'Sign' }));
102
101
 
@@ -212,7 +211,7 @@ describe('<DashboardItemOrder/> Contract', () => {
212
211
  // We have the success message.
213
212
  await screen.findByRole('heading', { name: 'Congratulations!' });
214
213
  screen.getByText(
215
- 'You will receive an email once your contract will be fully signed. You can now enroll in your course runs!',
214
+ 'You will receive an email once your contract will be fully signed. You can now finalize your subscription.',
216
215
  );
217
216
  const nextButton = screen.getByRole('button', { name: 'Next' });
218
217
 
@@ -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
+ }